diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2c308445..2e5c01ca 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: Android CI +name: CI on: push: @@ -6,41 +6,115 @@ on: pull_request: branches: [ "v2" ] +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + jobs: - ci: + detect-changes: + runs-on: ubuntu-latest + + outputs: + rust: ${{ steps.filter.outputs.rust }} + + steps: + - uses: actions/checkout@v6 + - uses: dorny/paths-filter@v4 + id: filter + with: + filters: | + rust: + - 'rust/**' + - '.github/workflows/ci.yaml' + # Fast quality gate: fmt + clippy only. Blocks both rust-test and android-test + # so a broken Rust change doesn't waste NDK cross-compilation time. + rust-lint: + needs: detect-changes + if: needs.detect-changes.outputs.rust == 'true' runs-on: ubuntu-latest + defaults: + run: + working-directory: ./rust/rust-code + steps: - - uses: actions/checkout@v4 - - name: set up JDK 21 - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - uses: actions/cache@v5 with: - java-version: '21' - distribution: 'temurin' - cache: gradle + path: | + ~/.cargo/registry + ~/.cargo/git + rust/rust-code/target + key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-host- + - name: Check formatting + run: cargo fmt --check --all + - name: Clippy + run: cargo clippy --workspace --all-targets -- -D warnings + + # Rust-native tests (no NDK). Runs in parallel with android-test once lint passes. + rust-test: + needs: [ detect-changes, rust-lint ] + if: needs.detect-changes.outputs.rust == 'true' + runs-on: ubuntu-latest - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + defaults: + run: + working-directory: ./rust/rust-code - - name: Cache Gradle packages - uses: actions/cache@v4 + steps: + - uses: actions/checkout@v6 + - uses: actions/cache@v5 with: path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- + ~/.cargo/registry + ~/.cargo/git + rust/rust-code/target + key: ${{ runner.os }}-cargo-host-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-host- + - name: Build + run: cargo build --workspace --all-targets --verbose + - name: Run tests + run: cargo test --workspace --verbose + + # Android unit tests. Waits for rust-lint when rust changed, so NDK cross-compilation isn't + # triggered on code that already fails fmt/clippy. + # buildRust (make all) runs internally via the preBuild Gradle hook and generates + # the UniFFI Kotlin interfaces that the tests compile against. + android-test: + needs: [ detect-changes, rust-lint ] + if: | + always() && + ( + needs.detect-changes.outputs.rust != 'true' || + needs.rust-lint.result == 'success' + ) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + - uses: gradle/actions/setup-gradle@v6 + - uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + rust/rust-code/target + key: ${{ runner.os }}-cargo-android-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-cargo-android- - name: Grant execute permission for gradlew run: chmod +x ./gradlew + - name: Run unit tests + run: ./gradlew testDebugUnitTest --continue - - name: Build Debug APK + - name: Assemble debug APK run: ./gradlew assembleDebug - - - name: Run all unit tests - run: ./gradlew test - - - name: Run debug-flavor unit tests - run: ./gradlew testDebugUnitTest \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2ed54774..0f9cb1be 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ *.tar.gz *.rar +# Libs +*.so + # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* diff --git a/CLAUDE.md b/CLAUDE.md index 25025af9..244e77f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Android password manager using Clean Architecture per module: | `:feature:*` | `list_screen`, `item:{core,create,view}`, `credentials`, `totp` | | `:automation` | Automation support + annotation processor | | `:migration-create-access` | v1 → v2 data migration (high risk) | -| `:rust` | Passkey operations via Rust JNI | +| `:rust` | Rust crypto/passkey ops via UniFFI-generated Kotlin bindings | ## Key Patterns @@ -70,7 +70,7 @@ prompt flow, or persistence semantics without explicit instruction. - **Migration** — preserve backward compat, smallest safe change - **Autofill** (`app/.../autofill/`) — constrained by Android framework, keep conservative -- **Rust FFI** — preserve type/memory-safety across JNI boundary +- **UniFFI** — preserve memory and type safety across the FFI boundary. - **Room schema** — check migration implications before changing entities ## Code Style @@ -85,3 +85,11 @@ prompt flow, or persistence semantics without explicit instruction. - kotlin-test + MockK + kotlinx-coroutines-test; Compose UI tests with Espresso - Use `runTest { }`, `mockk(relaxed = true)`, `coEvery { }`, assert against `Result` - Run broader tests for cross-module or security changes +- **Rust fakes** — `:rust` uses UniFFI (not raw JNI) to generate Kotlin bindings. UniFFI emits + `KeyDeriverInterface`/`KeyWrapperInterface`/`AccountManagerInterface` for test seams; fakes live + in `:rust` testFixtures (`de.davis.keygo.rust`). Never instantiate `KeyDeriver()`/`KeyWrapper()`/ + `AccountManager()` in JVM unit tests — their default constructors require the native Rust library + at runtime. +- **testFixtures + Compose plugin** — Any module with `kotlin.compose` that enables testFixtures + must add `testFixturesImplementation(libs.androidx.compose.runtime)` to avoid "Compose Runtime + not on classpath" compile errors. See `:core:item` for the canonical pattern. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41d79b02..89cb6477 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,6 +87,7 @@ dependencies { implementation(libs.koin.androidx.compose) implementation(libs.koin.annotations) + implementation(projects.rust) implementation(projects.core.item) implementation(projects.core.identity) implementation(projects.core.security) @@ -97,6 +98,7 @@ dependencies { implementation(projects.feature.item.core) implementation(projects.feature.item.create) implementation(projects.feature.item.view) + implementation(projects.feature.vault) implementation(projects.feature.totp) implementation(projects.feature.autofill) implementation(projects.migrationCreateAccess) diff --git a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/handler/ItemHandler.kt b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/handler/ItemHandler.kt index aa2da235..a813e5e2 100644 --- a/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/handler/ItemHandler.kt +++ b/automation-processor/src/main/kotlin/de/davis/keygo/automation/processor/handler/ItemHandler.kt @@ -54,8 +54,12 @@ class ItemHandler : Handler, KoinComponent ) }.toList() + val annotation = root.getAnnotation() + val simpleName = annotation?.name?.takeIf { it.isNotEmpty() } + ?: root.simpleName.asString() + Entry.RootEntry( - simpleName = root.simpleName.asString(), + simpleName = simpleName, packageName = root.packageName.asString(), children = subclasses ) diff --git a/core/identity/build.gradle.kts b/core/identity/build.gradle.kts index 7bd7c0f7..1f803b6d 100644 --- a/core/identity/build.gradle.kts +++ b/core/identity/build.gradle.kts @@ -31,6 +31,10 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + + testFixtures { + enable = true + } } kotlin { @@ -44,6 +48,8 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(projects.core.security) + implementation(projects.core.item) + implementation(projects.rust) // Datastore implementation(libs.androidx.datastore) @@ -53,8 +59,6 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.material3) - implementation(libs.argon2kt) - // Koin DI implementation(project.dependencies.platform(libs.koin.bom)) implementation(libs.koin.androidx.compose) @@ -63,6 +67,15 @@ dependencies { testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.io.mockk) + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.rust)) + + testFixturesApi(projects.core.util) + testFixturesImplementation(projects.rust) + testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) + testFixturesImplementation(libs.androidx.compose.runtime) { + because("https://issuetracker.google.com/issues/259523353#comment32") + } } protobuf { @@ -72,6 +85,10 @@ protobuf { generateProtoTasks { all().forEach { task -> + if (task.name.contains("TestFixtures", ignoreCase = true)) { + task.enabled = false + return@forEach + } task.builtins { create("java") { option("lite") diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/WrappedKeyRepositoryImpl.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/WrappedKeyRepositoryImpl.kt deleted file mode 100644 index 218440c3..00000000 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/WrappedKeyRepositoryImpl.kt +++ /dev/null @@ -1,45 +0,0 @@ -package de.davis.keygo.core.identity.data - -import androidx.datastore.core.DataStore -import de.davis.keygo.core.identity.data.local.model.ProtoBiometricKeyData -import de.davis.keygo.core.identity.data.local.model.ProtoPasswordKeyData -import de.davis.keygo.core.identity.data.mapper.toDomain -import de.davis.keygo.core.identity.data.mapper.toProto -import de.davis.keygo.core.identity.di.annotation.BiometricQualifier -import de.davis.keygo.core.identity.di.annotation.PasswordQualifier -import de.davis.keygo.core.identity.domain.model.BiometricWrappedKeyData -import de.davis.keygo.core.identity.domain.model.PasswordWrappedKeyData -import de.davis.keygo.core.identity.domain.repository.WrappedKeyRepository -import de.davis.keygo.core.util.Result -import de.davis.keygo.core.util.asResult -import kotlinx.coroutines.flow.firstOrNull -import org.koin.core.annotation.Single - -@Single -internal class WrappedKeyRepositoryImpl( - @param:BiometricQualifier - private val biometricDataStore: DataStore, - @param:PasswordQualifier - private val passwordDataStore: DataStore, -) : WrappedKeyRepository { - - override suspend fun getBiometricWrappedKey(): Result = - biometricDataStore.data.firstOrNull() - ?.toDomain() - ?.takeIf(BiometricWrappedKeyData::isValid) - .asResult(Unit) - - override suspend fun getPasswordWrappedKey(): Result = - passwordDataStore.data.firstOrNull() - ?.toDomain() - ?.takeIf(PasswordWrappedKeyData::isValid) - .asResult(Unit) - - override suspend fun setBiometricWrappedKey(data: BiometricWrappedKeyData) { - biometricDataStore.updateData { data.toProto() } - } - - override suspend fun setPasswordWrappedKey(data: PasswordWrappedKeyData) { - passwordDataStore.updateData { data.toProto() } - } -} \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/mapper/ProtoMapper.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/mapper/ProtoMapper.kt index a3c2b931..52070c22 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/mapper/ProtoMapper.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/mapper/ProtoMapper.kt @@ -1,31 +1,58 @@ package de.davis.keygo.core.identity.data.mapper import com.google.protobuf.kotlin.toByteString +import de.davis.keygo.core.identity.data.local.model.ProtoAccountState import de.davis.keygo.core.identity.data.local.model.ProtoBiometricKeyData import de.davis.keygo.core.identity.data.local.model.ProtoPasswordKeyData +import de.davis.keygo.core.identity.data.local.model.biometricKeyDataOrNull +import de.davis.keygo.core.identity.data.local.model.protoAccount +import de.davis.keygo.core.identity.data.local.model.protoAccountState import de.davis.keygo.core.identity.data.local.model.protoBiometricKeyData import de.davis.keygo.core.identity.data.local.model.protoPasswordKeyData -import de.davis.keygo.core.identity.domain.model.BiometricWrappedKeyData -import de.davis.keygo.core.identity.domain.model.PasswordWrappedKeyData +import de.davis.keygo.core.identity.domain.model.Account +import de.davis.keygo.core.identity.domain.model.BiometricWrappedArk +import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk +import java.util.UUID -internal fun ProtoBiometricKeyData.toDomain() = BiometricWrappedKeyData( +internal fun ProtoBiometricKeyData.toDomain() = BiometricWrappedArk( key = key.toByteArray(), keyIV = keyIV.toByteArray() ) -internal fun BiometricWrappedKeyData.toProto() = protoBiometricKeyData { +internal fun BiometricWrappedArk.toProto() = protoBiometricKeyData { key = this@toProto.key.toByteString() keyIV = this@toProto.keyIV.toByteString() } -internal fun ProtoPasswordKeyData.toDomain() = PasswordWrappedKeyData( +internal fun ProtoPasswordKeyData.toDomain() = PasswordWrappedArk( key = key.toByteArray(), keyIV = keyIV.toByteArray(), salt = salt.toByteArray() ) -internal fun PasswordWrappedKeyData.toProto() = protoPasswordKeyData { +internal fun PasswordWrappedArk.toProto() = protoPasswordKeyData { key = this@toProto.key.toByteString() keyIV = this@toProto.keyIV.toByteString() salt = this@toProto.salt.toByteString() +} + +internal fun ProtoAccountState.toDomain() = takeIf { it.hasAccount() }?.account?.let { + Account( + id = UUID.fromString(it.userId), + displayName = it.displayName, + createdAtEpochMillis = it.createdAtEpochMillis, + passwordWrappedArk = it.passwordKeyData.toDomain(), + biometricWrappedArk = it.biometricKeyDataOrNull?.toDomain(), + ) +} + +internal fun Account.toProto() = protoAccountState { + account = protoAccount { + userId = this@toProto.id.toString() + displayName = this@toProto.displayName + createdAtEpochMillis = this@toProto.createdAtEpochMillis + + passwordKeyData = passwordWrappedArk.toProto() + biometricWrappedArk?.let { biometricKeyData = it.toProto() } + } } \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt new file mode 100644 index 00000000..ba1be8b0 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/AccountRepositoryImpl.kt @@ -0,0 +1,28 @@ +package de.davis.keygo.core.identity.data.repository + +import androidx.datastore.core.DataStore +import de.davis.keygo.core.identity.data.local.model.ProtoAccountState +import de.davis.keygo.core.identity.data.mapper.toDomain +import de.davis.keygo.core.identity.data.mapper.toProto +import de.davis.keygo.core.identity.di.annotation.AccountRegistryQualifier +import de.davis.keygo.core.identity.domain.model.Account +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.first +import org.koin.core.annotation.Single + +@Single +internal class AccountRepositoryImpl( + @param:AccountRegistryQualifier + private val dataStore: DataStore, +) : AccountRepository { + + override suspend fun getOrNull(): Account? = dataStore.data.first().toDomain() + + override suspend fun set(account: Account): Result = runCatching { + dataStore.updateData { account.toProto() } + }.fold( + onSuccess = { Result.Success(Unit) }, + onFailure = { Result.Failure(Unit) } + ) +} diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/DeviceInfoRepositoryImpl.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/DeviceInfoRepositoryImpl.kt deleted file mode 100644 index 438324bd..00000000 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/DeviceInfoRepositoryImpl.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.davis.keygo.core.identity.data.repository - -import de.davis.keygo.core.identity.domain.repository.DeviceInfoRepository -import org.koin.core.annotation.Single - -@Single -internal class DeviceInfoRepositoryImpl : DeviceInfoRepository { - - override fun getNumCors(): Int = Runtime.getRuntime().availableProcessors() -} \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/KeyDerivationRepositoryImpl.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/KeyDerivationRepositoryImpl.kt deleted file mode 100644 index 428affe2..00000000 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/data/repository/KeyDerivationRepositoryImpl.kt +++ /dev/null @@ -1,37 +0,0 @@ -package de.davis.keygo.core.identity.data.repository - -import com.lambdapioneer.argon2kt.Argon2Kt -import com.lambdapioneer.argon2kt.Argon2Mode -import com.lambdapioneer.argon2kt.Argon2Version -import de.davis.keygo.core.identity.domain.repository.KeyDerivationRepository -import de.davis.keygo.core.util.Result -import org.koin.core.annotation.Single - -@Single -internal class KeyDerivationRepositoryImpl( - private val argon2Kt: Argon2Kt, -) : KeyDerivationRepository { - - override suspend fun deriveKey( - password: ByteArray, - salt: ByteArray, - timeCost: Int, - memoryCostInKiB: Int, - parallelism: Int, - outputLengthInBytes: Int - ): Result = runCatching { - argon2Kt.hash( - mode = Argon2Mode.ARGON2_ID, - password = password, - salt = salt, - tCostInIterations = timeCost, - mCostInKibibyte = memoryCostInKiB, - parallelism = parallelism, - hashLengthInBytes = outputLengthInBytes, - version = Argon2Version.V13 - ) - }.fold( - onSuccess = { r -> Result.Success(r.rawHashAsByteArray()) }, - onFailure = { Result.Failure(it.message ?: "") } - ) -} \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/CoreIdentityModule.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/CoreIdentityModule.kt index a5433bfa..9386f7dd 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/CoreIdentityModule.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/CoreIdentityModule.kt @@ -2,11 +2,8 @@ package de.davis.keygo.core.identity.di import android.content.Context import androidx.datastore.dataStore -import com.lambdapioneer.argon2kt.Argon2Kt -import de.davis.keygo.core.identity.data.local.model.ProtoBiometricKeyData -import de.davis.keygo.core.identity.data.local.model.ProtoPasswordKeyData -import de.davis.keygo.core.identity.di.annotation.BiometricQualifier -import de.davis.keygo.core.identity.di.annotation.PasswordQualifier +import de.davis.keygo.core.identity.data.local.model.ProtoAccountState +import de.davis.keygo.core.identity.di.annotation.AccountRegistryQualifier import de.davis.keygo.core.security.di.CoreSecurityModule import org.koin.core.annotation.ComponentScan import org.koin.core.annotation.Configuration @@ -17,32 +14,16 @@ import org.koin.core.annotation.Single @Configuration @ComponentScan("de.davis.keygo.core.identity") object CoreIdentityModule { - - private val Context.protoBiometricKeyDataStore by dataStore( - "biometric_key_data.pb", - DefaultProtoSerializer( - defaultInstance = ProtoBiometricKeyData.getDefaultInstance(), - parser = ProtoBiometricKeyData.parser() - ) - ) - - private val Context.protoPasswordKeyDataStore by dataStore( - "password_key_data.pb", + private val Context.protoAccountStateDataStore by dataStore( + "account_state.pb", DefaultProtoSerializer( - defaultInstance = ProtoPasswordKeyData.getDefaultInstance(), - parser = ProtoPasswordKeyData.parser() + defaultInstance = ProtoAccountState.getDefaultInstance(), + parser = ProtoAccountState.parser() ) ) @Single - @BiometricQualifier - internal fun provideBiometricKeyDataStore(context: Context) = context.protoBiometricKeyDataStore - - @Single - @PasswordQualifier - internal fun providePasswordKeyDataStore(context: Context) = context.protoPasswordKeyDataStore - - - @Single - internal fun provideArgon2Kt() = Argon2Kt() + @AccountRegistryQualifier + internal fun provideAccountDataStore(context: Context) = + context.protoAccountStateDataStore } \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/annotation/Qualifiers.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/annotation/Qualifiers.kt index 301190c1..ccfc18d1 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/annotation/Qualifiers.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/di/annotation/Qualifiers.kt @@ -3,7 +3,4 @@ package de.davis.keygo.core.identity.di.annotation import org.koin.core.annotation.Named @Named -internal annotation class BiometricQualifier - -@Named -internal annotation class PasswordQualifier +internal annotation class AccountRegistryQualifier diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Account.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Account.kt new file mode 100644 index 00000000..dd15414c --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/Account.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.core.identity.domain.model + +import java.util.UUID + +data class Account( + val id: UUID, + val displayName: String, + val passwordWrappedArk: PasswordWrappedArk, + val biometricWrappedArk: BiometricWrappedArk?, + val createdAtEpochMillis: Long = System.currentTimeMillis(), +) diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricWrappedKeyData.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricWrappedArk.kt similarity index 89% rename from core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricWrappedKeyData.kt rename to core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricWrappedArk.kt index 83d7454b..54902bf3 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricWrappedKeyData.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/BiometricWrappedArk.kt @@ -1,6 +1,6 @@ package de.davis.keygo.core.identity.domain.model -data class BiometricWrappedKeyData( +data class BiometricWrappedArk( val key: ByteArray, val keyIV: ByteArray ) { @@ -8,7 +8,7 @@ data class BiometricWrappedKeyData( if (this === other) return true if (javaClass != other?.javaClass) return false - other as BiometricWrappedKeyData + other as BiometricWrappedArk if (!key.contentEquals(other.key)) return false if (!keyIV.contentEquals(other.keyIV)) return false diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/CreateAccessError.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/CreateAccessError.kt index 8fa3fb8e..ffbe7131 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/CreateAccessError.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/CreateAccessError.kt @@ -3,4 +3,6 @@ package de.davis.keygo.core.identity.domain.model sealed interface CreateAccessError { data object KeyDerivationFailed : CreateAccessError data object WrappingFailed : CreateAccessError + data object AccountPersistenceFailed : CreateAccessError + data class VaultPersistenceFailed(val cause: Throwable) : CreateAccessError } \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/PasswordWrappedKeyData.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/PasswordWrappedArk.kt similarity index 91% rename from core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/PasswordWrappedKeyData.kt rename to core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/PasswordWrappedArk.kt index 82c9fd17..65da8c02 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/PasswordWrappedKeyData.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/PasswordWrappedArk.kt @@ -1,6 +1,6 @@ package de.davis.keygo.core.identity.domain.model -data class PasswordWrappedKeyData( +data class PasswordWrappedArk( val key: ByteArray, val keyIV: ByteArray, val salt: ByteArray @@ -9,7 +9,7 @@ data class PasswordWrappedKeyData( if (this === other) return true if (javaClass != other?.javaClass) return false - other as PasswordWrappedKeyData + other as PasswordWrappedArk if (!key.contentEquals(other.key)) return false if (!keyIV.contentEquals(other.keyIV)) return false diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockError.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockError.kt index a940195c..d6357442 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockError.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/model/UnlockError.kt @@ -4,4 +4,5 @@ sealed interface UnlockError { data object WrappedKeyNotFound : UnlockError data object UnwrappingFailed : UnlockError data object DerivationFailed : UnlockError + data object ActiveAccountNotFound : UnlockError } \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt new file mode 100644 index 00000000..e90ba0b7 --- /dev/null +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/AccountRepository.kt @@ -0,0 +1,31 @@ +package de.davis.keygo.core.identity.domain.repository + +import de.davis.keygo.core.identity.domain.model.Account +import de.davis.keygo.core.util.Result + +/** + * Persists account identity metadata and associated cryptographic key-wrapping data. + * * This repository manages [Account] objects, which bundle non-secret identifiers (ID, + * display name) with the sensitive, opaque blobs required to unlock account secrets + *. + * + * The backing store is an account registry, allowing for multi-account + * support by managing an active account ID. For now, the UI primarily + * interacts with the single active account. + * + * Note: This repository persists the opaque key-wrapping metadata per account. + * These contain the wrapped keys and their respective Initialization Vectors (IVs) + */ +interface AccountRepository { + + /** + * Returns the currently active account, or null if no account is registered. + */ + suspend fun getOrNull(): Account? + + /** + * Persists or updates an account, including its identity and associated + * security key metadata. + */ + suspend fun set(account: Account): Result +} diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/DeviceInfoRepository.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/DeviceInfoRepository.kt deleted file mode 100644 index 2386cd00..00000000 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/DeviceInfoRepository.kt +++ /dev/null @@ -1,6 +0,0 @@ -package de.davis.keygo.core.identity.domain.repository - -interface DeviceInfoRepository { - - fun getNumCors(): Int -} \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/KeyDerivationRepository.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/KeyDerivationRepository.kt deleted file mode 100644 index 24b446e7..00000000 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/KeyDerivationRepository.kt +++ /dev/null @@ -1,15 +0,0 @@ -package de.davis.keygo.core.identity.domain.repository - -import de.davis.keygo.core.util.Result - -interface KeyDerivationRepository { - - suspend fun deriveKey( - password: ByteArray, - salt: ByteArray, - timeCost: Int = 3, - memoryCostInKiB: Int = 131_072, /* 128 MiB */ - parallelism: Int = 4, - outputLengthInBytes: Int = 32, /* 256 bits */ - ): Result -} \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/WrappedKeyRepository.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/WrappedKeyRepository.kt deleted file mode 100644 index db90f691..00000000 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/repository/WrappedKeyRepository.kt +++ /dev/null @@ -1,19 +0,0 @@ -package de.davis.keygo.core.identity.domain.repository - -import de.davis.keygo.core.identity.domain.model.BiometricWrappedKeyData -import de.davis.keygo.core.identity.domain.model.PasswordWrappedKeyData -import de.davis.keygo.core.util.Result - -/** - * A repository that provides access to wrapped keys. These keys are wrapped using either biometric - * authentication or the main password.The unwrapped key is the actual key used for encryption and - * decryption operations. - */ -interface WrappedKeyRepository { - - suspend fun getBiometricWrappedKey(): Result - suspend fun getPasswordWrappedKey(): Result - - suspend fun setBiometricWrappedKey(data: BiometricWrappedKeyData) - suspend fun setPasswordWrappedKey(data: PasswordWrappedKeyData) -} \ No newline at end of file diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt index ffc8e4f7..654c34ae 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCase.kt @@ -1,102 +1,144 @@ package de.davis.keygo.core.identity.domain.usecase -import android.security.keystore.KeyProperties -import de.davis.keygo.core.identity.domain.model.BiometricWrappedKeyData +import de.davis.keygo.core.identity.domain.model.Account +import de.davis.keygo.core.identity.domain.model.BiometricWrappedArk import de.davis.keygo.core.identity.domain.model.CreateAccessError -import de.davis.keygo.core.identity.domain.model.PasswordWrappedKeyData -import de.davis.keygo.core.identity.domain.repository.DeviceInfoRepository -import de.davis.keygo.core.identity.domain.repository.KeyDerivationRepository -import de.davis.keygo.core.identity.domain.repository.WrappedKeyRepository +import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.model.asAesKey import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.asResult import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.rust.account.AccountManager +import de.davis.keygo.rust.derive.KeyDeriver +import de.davis.keygo.rust.derive.deriveRootKekFromPasswordWithResult +import de.davis.keygo.rust.wrap.KeyWrapper +import de.davis.keygo.rust.wrap.wrapAccountRootKeyWithResult +import de.davis.keygo.rust.wrap.wrapVaultKeyWithResult +import de.davisalessandro.keygo.rust.AccountRootKey +import de.davisalessandro.keygo.rust.RootKek import org.koin.core.annotation.Single -import java.security.Key -import java.security.SecureRandom import javax.crypto.Cipher -import javax.crypto.KeyGenerator import javax.crypto.spec.SecretKeySpec +import de.davisalessandro.keygo.rust.Account as RustAccount @Single class CreateAccessUseCase( - private val keyDerivationRepository: KeyDerivationRepository, - private val deviceInfoRepository: DeviceInfoRepository, - private val wrappedKeyRepository: WrappedKeyRepository, + private val keyDeriver: KeyDeriver, + private val keyWrapper: KeyWrapper, + private val accountManager: AccountManager, + private val accountRepository: AccountRepository, + private val vaultRepository: VaultRepository, + private val vaultContextRepository: VaultContextRepository, private val session: Session ) { /** - * Use case to create access by generating a new Data Encryption Key (DEK), that is then wrapped - * with a Key Encryption Key (KEK) derived from the user's password. Optionally, the DEK can also be - * wrapped with a KEK derived from biometric data. + * Use case to create access by generating a new account and vault, which are then wrapped + * with a Key Encryption Key (KEK) derived from the user's password. Optionally, the ARK + * (AccountRootKey) can also be wrapped with a KEK derived from biometric data. * - * The generated DEK is stored in the session for immediate use. The password-wrapped DEK and, - * if applicable, the biometric-wrapped DEK are stored in the [WrappedKeyRepository] for future + * The generated ARK is stored in the session for immediate use. The password-wrapped ARK and, + * if applicable, the biometric-wrapped ARK are stored in the [AccountRepository] for future * retrieval. * - * @param password The user's password used to derive the KEK for wrapping the DEK. - * @param biometricCipher An optional [Cipher] initialized for wrapping the DEK with biometric data. + * @param password The user's password used to derive the KEK for wrapping the ARK. + * @param biometricCipher An optional [Cipher] initialized for wrapping the ARK with biometric data. */ suspend operator fun invoke( password: String, - biometricCipher: Cipher? = null - ): Result = KeyGenerator.getInstance("AES").apply { - init(256) - }.generateKey().let { keyToWrap -> - val random = SecureRandom() - val salt = ByteArray(16) - random.nextBytes(salt) - - val derivedKek = keyDerivationRepository.deriveKey( - password = password.toByteArray(), + biometricCipher: Cipher? = null, + vaultName: String = "Default Vault", + accountDisplayName: String = "Default Account", + ): Result { + val salt = keyDeriver.generateSalt() + val derivedKek = keyDeriver.deriveRootKekFromPasswordWithResult( + password = password, salt = salt, - parallelism = deviceInfoRepository.getNumCors() - ).getOrNull()?.let { - SecretKeySpec(it, 0, it.size, "AES") - } ?: return Result.Failure(CreateAccessError.KeyDerivationFailed) - - keyToWrap.wrapUsing(derivedKek)?.let { (passwordWrappedDek, iv) -> - wrappedKeyRepository.setPasswordWrappedKey( - PasswordWrappedKeyData( - key = passwordWrappedDek, - keyIV = iv, - salt = salt - ) - ) - } ?: return Result.Failure(CreateAccessError.WrappingFailed) + ).getOrNull() ?: return Result.Failure(CreateAccessError.KeyDerivationFailed) + + val accountHolder = accountManager.createAccount() + + val passwordWrappedArk = + getPasswordWrappedArk(accountHolder.account, derivedKek, salt).getOrNull() + ?: return Result.Failure(CreateAccessError.WrappingFailed) + val wrappedVaultKey = accountHolder.defaultVault.wrap(accountHolder.account.ark).getOrNull() + ?: return Result.Failure(CreateAccessError.WrappingFailed) - if (biometricCipher == null) { - session.startSession(keyToWrap.asAesKey()) - return Result.Success(Unit) + val biometricWrappedArk = biometricCipher?.let { + getBiometricWrappedArk(accountHolder.account, it).getOrNull() + ?: return Result.Failure(CreateAccessError.WrappingFailed) } - keyToWrap.wrapUsingCipher(biometricCipher)?.let { (biometricWrappedDek, iv) -> - wrappedKeyRepository.setBiometricWrappedKey( - BiometricWrappedKeyData( - key = biometricWrappedDek, - keyIV = iv + // Persist the account before the vault: the vault is encrypted under the account's + // ARK, so a vault row without a recoverable account is dead weight. If the vault + // write fails after this, the half-state is recoverable on retry — `set` overwrites. + accountRepository.set( + Account( + id = accountHolder.account.id, + displayName = accountDisplayName, + passwordWrappedArk = passwordWrappedArk, + biometricWrappedArk = biometricWrappedArk, + ) + ).getOrNull() ?: return Result.Failure(CreateAccessError.AccountPersistenceFailed) + + // TODO: use CreateVaultUseCase, when having better project structure + runCatching { + vaultRepository.createVault( + Vault( + id = accountHolder.defaultVault.id, + name = vaultName, + wrappedVaultKey = wrappedVaultKey.ciphertext, + vaultKeyNonce = wrappedVaultKey.nonce, + icon = Vault.Icon.Default, ) ) - } ?: return Result.Failure(CreateAccessError.WrappingFailed) + }.onFailure { return Result.Failure(CreateAccessError.VaultPersistenceFailed(it)) } + + vaultContextRepository.setContextAndLastInteracted(accountHolder.defaultVault.id) - session.startSession(keyToWrap.asAesKey()) + session.startSession(accountHolder.account.ark.asAesKey()) return Result.Success(Unit) } - private fun Key.wrapUsing(key: Key): Pair? { - val cipher = Cipher.getInstance( - "${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/${KeyProperties.ENCRYPTION_PADDING_NONE}" - ) - cipher.init(Cipher.WRAP_MODE, key) - return wrapUsingCipher(cipher) - } + private fun getPasswordWrappedArk( + account: RustAccount, + derivedKek: RootKek, + salt: ByteArray + ) = account.wrap(derivedKek) + .getOrNull() + ?.let { wrappedKey -> + PasswordWrappedArk( + key = wrappedKey.ciphertext, + keyIV = wrappedKey.nonce, + salt = salt + ) + }.asResult(CreateAccessError.WrappingFailed) - private fun Key.wrapUsingCipher(cipher: Cipher): Pair? { - return runCatching { - cipher.wrap(this) to cipher.iv - }.getOrNull() - } + private fun getBiometricWrappedArk( + account: RustAccount, + biometricCipher: Cipher + ) = account.wrapUsingCipher(biometricCipher) + ?.let { (wrappedKey, iv) -> + BiometricWrappedArk( + key = wrappedKey, + keyIV = iv + ) + }.asResult(CreateAccessError.WrappingFailed) + + private fun de.davisalessandro.keygo.rust.Vault.wrap(ark: AccountRootKey) = + keyWrapper.wrapVaultKeyWithResult(ark, vaultKey, id) + + private fun RustAccount.wrap(kek: RootKek) = + keyWrapper.wrapAccountRootKeyWithResult(kek, ark, id) + + private fun RustAccount.wrapUsingCipher(cipher: Cipher) = runCatching { + cipher.wrap(SecretKeySpec(ark, 0, ark.size, "AES")) to cipher.iv + }.getOrNull() } diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCase.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCase.kt index 5c9fe07b..6afaa26b 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCase.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCase.kt @@ -1,61 +1,55 @@ package de.davis.keygo.core.identity.domain.usecase -import android.security.keystore.KeyProperties -import de.davis.keygo.core.identity.domain.model.PasswordWrappedKeyData +import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk import de.davis.keygo.core.identity.domain.model.UnlockError -import de.davis.keygo.core.identity.domain.repository.DeviceInfoRepository -import de.davis.keygo.core.identity.domain.repository.KeyDerivationRepository -import de.davis.keygo.core.identity.domain.repository.WrappedKeyRepository +import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.model.asAesKey import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.rust.derive.KeyDeriver +import de.davis.keygo.rust.derive.deriveRootKekFromPasswordWithResult +import de.davis.keygo.rust.wrap.KeyWrapper +import de.davis.keygo.rust.wrap.unwrapAccountRootKeyWithResult +import de.davisalessandro.keygo.rust.AccountRootKey +import de.davisalessandro.keygo.rust.KeyWrapException +import de.davisalessandro.keygo.rust.RootKek +import de.davisalessandro.keygo.rust.WrappedKeyBlob import org.koin.core.annotation.Single -import java.security.Key -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec +import java.util.UUID @Single class UnlockWithPasswordUseCase( private val session: Session, - private val wrappedKeyRepository: WrappedKeyRepository, - private val keyDerivationRepository: KeyDerivationRepository, - private val deviceInfoRepository: DeviceInfoRepository + private val accountRepository: AccountRepository, + private val keyDeriver: KeyDeriver, + private val keyWrapper: KeyWrapper, ) { suspend operator fun invoke(password: String): Result { - val wrappedKey = wrappedKeyRepository.getPasswordWrappedKey().getOrNull() - ?: return Result.Failure(UnlockError.WrappedKeyNotFound) + val account = accountRepository.getOrNull() + ?: return Result.Failure(UnlockError.ActiveAccountNotFound) - val derivedKey = keyDerivationRepository.deriveKey( - password = password.toByteArray(), + val wrappedKey = account.passwordWrappedArk + val derivedKey = keyDeriver.deriveRootKekFromPasswordWithResult( + password = password, salt = wrappedKey.salt, - parallelism = deviceInfoRepository.getNumCors(), - ).getOrNull()?.let { - SecretKeySpec(it, 0, it.size, "AES") - } ?: return Result.Failure(UnlockError.DerivationFailed) + ).getOrNull() ?: return Result.Failure(UnlockError.DerivationFailed) - val key = wrappedKey.unwrapUsing(derivedKey) + val key = wrappedKey.unwrapUsing(derivedKey, account.id) + .getOrNull() ?: return Result.Failure(UnlockError.UnwrappingFailed) session.startSession(key.asAesKey()) return Result.Success(Unit) } - private fun PasswordWrappedKeyData.unwrapUsing(key: Key): Key? { - val cipher = Cipher.getInstance( - "${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_GCM}/${KeyProperties.ENCRYPTION_PADDING_NONE}" - ) - cipher.init(Cipher.UNWRAP_MODE, key, GCMParameterSpec(AUTH_TAG_LENGTH, this.keyIV)) - - return runCatching { - cipher.unwrap(this.key, "AES", Cipher.SECRET_KEY) - }.getOrNull() - } - - - private companion object { - private const val AUTH_TAG_LENGTH = 128 - } + private fun PasswordWrappedArk.unwrapUsing( + kek: RootKek, + userId: UUID, + ): Result = keyWrapper.unwrapAccountRootKeyWithResult( + kek = kek, + wrapped = WrappedKeyBlob(ciphertext = this.key, nonce = this.keyIV), + userId = userId, + ) } diff --git a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImpl.kt b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImpl.kt index 23c43f6e..4fae405b 100644 --- a/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImpl.kt +++ b/core/identity/src/main/kotlin/de/davis/keygo/core/identity/presentation/BiometricUnlockAdapterImpl.kt @@ -3,7 +3,7 @@ package de.davis.keygo.core.identity.presentation import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import de.davis.keygo.core.identity.domain.model.UnlockError -import de.davis.keygo.core.identity.domain.repository.WrappedKeyRepository +import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.model.asAesKey import de.davis.keygo.core.security.domain.model.BiometricPolicy @@ -18,13 +18,13 @@ import org.koin.core.annotation.Single @Single internal class BiometricUnlockAdapterImpl( private val session: Session, - private val wrappedKeyRepository: WrappedKeyRepository + private val accountRepository: AccountRepository, ) : BiometricUnlockAdapter { override suspend fun BiometricCryptoController.requestUnlockVault( policy: BiometricPolicy ): Result { - val wrappedKey = wrappedKeyRepository.getBiometricWrappedKey().getOrNull() + val wrappedKey = accountRepository.getOrNull()?.biometricWrappedArk ?: return Result.Failure(UnlockError.WrappedKeyNotFound) val result = requestUnwrap( @@ -44,12 +44,12 @@ internal class BiometricUnlockAdapterImpl( @Composable fun rememberBiometricUnlockAdapter(): BiometricUnlockAdapter { val session = koinInject() - val wrappedKeyRepository = koinInject() + val accountRepository = koinInject() - return remember(session, wrappedKeyRepository) { + return remember(session, accountRepository) { BiometricUnlockAdapterImpl( session = session, - wrappedKeyRepository = wrappedKeyRepository + accountRepository = accountRepository ) } } \ No newline at end of file diff --git a/core/identity/src/main/proto/account_registry.proto b/core/identity/src/main/proto/account_registry.proto new file mode 100644 index 00000000..b6ea03a6 --- /dev/null +++ b/core/identity/src/main/proto/account_registry.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package de.davis.keygo.core.identity.data.local.model; +option java_multiple_files = true; + +import "passsword_key_data.proto"; +import "biometric_key_data.proto"; + +message ProtoAccountState { + ProtoAccount account = 1; +} + +message ProtoAccount { + string user_id = 1; + string display_name = 2; + int64 created_at_epoch_millis = 3; + + ProtoPasswordKeyData password_key_data = 4; + optional ProtoBiometricKeyData biometric_key_data = 5; +} diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/model/WrappedKeyDataTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/model/WrappedKeyDataTest.kt index 91ec347d..303e5aab 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/model/WrappedKeyDataTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/model/WrappedKeyDataTest.kt @@ -8,7 +8,7 @@ class WrappedKeyDataTest { @Test fun `PasswordWrappedKeyData is valid when all fields non-empty`() { - val data = PasswordWrappedKeyData( + val data = PasswordWrappedArk( key = byteArrayOf(1, 2, 3), keyIV = byteArrayOf(4, 5, 6), salt = byteArrayOf(7, 8, 9) @@ -18,7 +18,7 @@ class WrappedKeyDataTest { @Test fun `PasswordWrappedKeyData is invalid when key is empty`() { - val data = PasswordWrappedKeyData( + val data = PasswordWrappedArk( key = byteArrayOf(), keyIV = byteArrayOf(4, 5, 6), salt = byteArrayOf(7, 8, 9) @@ -28,7 +28,7 @@ class WrappedKeyDataTest { @Test fun `PasswordWrappedKeyData is invalid when keyIV is empty`() { - val data = PasswordWrappedKeyData( + val data = PasswordWrappedArk( key = byteArrayOf(1, 2, 3), keyIV = byteArrayOf(), salt = byteArrayOf(7, 8, 9) @@ -38,7 +38,7 @@ class WrappedKeyDataTest { @Test fun `PasswordWrappedKeyData is invalid when salt is empty`() { - val data = PasswordWrappedKeyData( + val data = PasswordWrappedArk( key = byteArrayOf(1, 2, 3), keyIV = byteArrayOf(4, 5, 6), salt = byteArrayOf() @@ -48,12 +48,12 @@ class WrappedKeyDataTest { @Test fun `PasswordWrappedKeyData equality uses content comparison`() { - val data1 = PasswordWrappedKeyData( + val data1 = PasswordWrappedArk( key = byteArrayOf(1, 2, 3), keyIV = byteArrayOf(4, 5, 6), salt = byteArrayOf(7, 8, 9) ) - val data2 = PasswordWrappedKeyData( + val data2 = PasswordWrappedArk( key = byteArrayOf(1, 2, 3), keyIV = byteArrayOf(4, 5, 6), salt = byteArrayOf(7, 8, 9) @@ -64,12 +64,12 @@ class WrappedKeyDataTest { @Test fun `PasswordWrappedKeyData inequality when key differs`() { - val data1 = PasswordWrappedKeyData( + val data1 = PasswordWrappedArk( key = byteArrayOf(1, 2, 3), keyIV = byteArrayOf(4, 5, 6), salt = byteArrayOf(7, 8, 9) ) - val data2 = PasswordWrappedKeyData( + val data2 = PasswordWrappedArk( key = byteArrayOf(9, 8, 7), keyIV = byteArrayOf(4, 5, 6), salt = byteArrayOf(7, 8, 9) @@ -79,7 +79,7 @@ class WrappedKeyDataTest { @Test fun `BiometricWrappedKeyData is valid when all fields non-empty`() { - val data = BiometricWrappedKeyData( + val data = BiometricWrappedArk( key = byteArrayOf(1, 2, 3), keyIV = byteArrayOf(4, 5, 6) ) @@ -88,7 +88,7 @@ class WrappedKeyDataTest { @Test fun `BiometricWrappedKeyData is invalid when key is empty`() { - val data = BiometricWrappedKeyData( + val data = BiometricWrappedArk( key = byteArrayOf(), keyIV = byteArrayOf(4, 5, 6) ) @@ -97,7 +97,7 @@ class WrappedKeyDataTest { @Test fun `BiometricWrappedKeyData is invalid when keyIV is empty`() { - val data = BiometricWrappedKeyData( + val data = BiometricWrappedArk( key = byteArrayOf(1, 2, 3), keyIV = byteArrayOf() ) @@ -106,11 +106,11 @@ class WrappedKeyDataTest { @Test fun `BiometricWrappedKeyData equality uses content comparison`() { - val data1 = BiometricWrappedKeyData( + val data1 = BiometricWrappedArk( key = byteArrayOf(1, 2, 3), keyIV = byteArrayOf(4, 5, 6) ) - val data2 = BiometricWrappedKeyData( + val data2 = BiometricWrappedArk( key = byteArrayOf(1, 2, 3), keyIV = byteArrayOf(4, 5, 6) ) diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCaseTest.kt index 6d7a220f..8712a15e 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCaseTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/CreateAccessUseCaseTest.kt @@ -1,65 +1,51 @@ package de.davis.keygo.core.identity.domain.usecase -import de.davis.keygo.core.identity.domain.model.BiometricWrappedKeyData +import de.davis.keygo.core.identity.FakeAccountRepository import de.davis.keygo.core.identity.domain.model.CreateAccessError -import de.davis.keygo.core.identity.domain.model.PasswordWrappedKeyData -import de.davis.keygo.core.identity.domain.repository.DeviceInfoRepository -import de.davis.keygo.core.identity.domain.repository.KeyDerivationRepository -import de.davis.keygo.core.identity.domain.repository.WrappedKeyRepository +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.FakeVaultRepository import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.model.AesKey -import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every +import de.davis.keygo.rust.FakeAccountManager +import de.davis.keygo.rust.FakeKeyDeriver +import de.davis.keygo.rust.FakeKeyWrapper import io.mockk.mockk import io.mockk.slot import io.mockk.verify +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import javax.crypto.Cipher import javax.crypto.KeyGenerator import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue class CreateAccessUseCaseTest { private val session = mockk(relaxed = true) - private val wrappedKeyRepository = mockk(relaxed = true) - private val keyDerivationRepository = mockk() - private val deviceInfoRepository = mockk() + private val accountRepository = FakeAccountRepository() + private val vaultRepository = FakeVaultRepository() + private val vaultContextRepository = FakeVaultContextRepository() + private val keyDeriver = FakeKeyDeriver() + private val keyWrapper = FakeKeyWrapper() + private val accountManager = FakeAccountManager() private val useCase = CreateAccessUseCase( - keyDerivationRepository = keyDerivationRepository, - deviceInfoRepository = deviceInfoRepository, - wrappedKeyRepository = wrappedKeyRepository, - session = session + keyDeriver = keyDeriver, + keyWrapper = keyWrapper, + accountManager = accountManager, + accountRepository = accountRepository, + vaultRepository = vaultRepository, + vaultContextRepository = vaultContextRepository, + session = session, ) - private fun setupSuccessfulDerivation() { - val kekBytes = ByteArray(32) { it.toByte() } - coEvery { - keyDerivationRepository.deriveKey( - password = any(), - salt = any(), - parallelism = any() - ) - } returns Result.Success(kekBytes) - every { deviceInfoRepository.getNumCors() } returns 4 - } - @Test fun `returns KeyDerivationFailed when derivation fails`() = runTest { - coEvery { - keyDerivationRepository.deriveKey( - password = any(), - salt = any(), - parallelism = any() - ) - } returns Result.Failure("error") - every { deviceInfoRepository.getNumCors() } returns 4 + keyDeriver.failDerivation = true val result = useCase("password") @@ -68,122 +54,106 @@ class CreateAccessUseCaseTest { } @Test - fun `returns Success and starts session without biometric cipher`() = runTest { - setupSuccessfulDerivation() + fun `returns AccountPersistenceFailed when account repository rejects set`() = runTest { + accountRepository.setFails = true - val result = useCase("password", biometricCipher = null) + val result = useCase("password") - assertTrue(result.isSuccess()) - val dekSlot = slot() - verify { session.startSession(capture(dekSlot)) } - assertEquals(32, dekSlot.captured.key.encoded.size) + assertTrue(result.isFailure()) + assertEquals(CreateAccessError.AccountPersistenceFailed, result.error) } @Test - fun `stores password wrapped key on success`() = runTest { - setupSuccessfulDerivation() + fun `does not persist a vault when account persistence fails`() = runTest { + accountRepository.setFails = true useCase("password") - val pwSlot = slot() - coVerify { wrappedKeyRepository.setPasswordWrappedKey(capture(pwSlot)) } - assertTrue(pwSlot.captured.key.isNotEmpty()) - assertTrue(pwSlot.captured.keyIV.isNotEmpty()) - assertTrue(pwSlot.captured.salt.isNotEmpty()) + assertTrue(vaultRepository.observeVaults().first().isEmpty()) } @Test - fun `salt is 16 bytes`() = runTest { - setupSuccessfulDerivation() + fun `reports VaultPersistenceFailed and leaves account persisted when vault create throws`() = + runTest { + val cause = RuntimeException("disk full") + vaultRepository.createError = cause + + val result = useCase("password") + + assertTrue(result.isFailure()) + val error = result.error + assertTrue(error is CreateAccessError.VaultPersistenceFailed) + assertEquals(cause, error.cause) + // Account is durable so a retry can proceed without orphaning a half-account. + assertNotNull(accountRepository.getOrNull()) + } - useCase("password") + @Test + fun `returns Success and starts session without biometric cipher`() = runTest { + val result = useCase("password", biometricCipher = null) - val pwSlot = slot() - coVerify { wrappedKeyRepository.setPasswordWrappedKey(capture(pwSlot)) } - assertEquals(16, pwSlot.captured.salt.size) + assertTrue(result.isSuccess()) + val dekSlot = slot() + verify { session.startSession(capture(dekSlot)) } + assertEquals(32, dekSlot.captured.key.encoded.size) } @Test - fun `stores biometric wrapped key when cipher provided`() = runTest { - setupSuccessfulDerivation() + fun `persists account with password-wrapped ARK on success`() = runTest { + useCase("password") + val stored = assertNotNull(accountRepository.getOrNull()) + assertTrue(stored.passwordWrappedArk.key.isNotEmpty()) + assertTrue(stored.passwordWrappedArk.keyIV.isNotEmpty()) + assertEquals(16, stored.passwordWrappedArk.salt.size) + } + + @Test + fun `persists biometric-wrapped ARK when cipher is provided`() = runTest { val biometricKek = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - val biometricCipher = Cipher.getInstance("AES/GCM/NoPadding") - biometricCipher.init(Cipher.WRAP_MODE, biometricKek) + val biometricCipher = Cipher.getInstance("AES/GCM/NoPadding").apply { + init(Cipher.WRAP_MODE, biometricKek) + } val result = useCase("password", biometricCipher = biometricCipher) assertTrue(result.isSuccess()) - val bioSlot = slot() - coVerify { wrappedKeyRepository.setBiometricWrappedKey(capture(bioSlot)) } - assertTrue(bioSlot.captured.key.isNotEmpty()) - assertTrue(bioSlot.captured.keyIV.isNotEmpty()) + val stored = accountRepository.getOrNull()!! + val bio = assertNotNull(stored.biometricWrappedArk) + assertTrue(bio.key.isNotEmpty()) + assertTrue(bio.keyIV.isNotEmpty()) } @Test - fun `does not store biometric key when no cipher provided`() = runTest { - setupSuccessfulDerivation() - + fun `does not persist biometric-wrapped ARK when no cipher provided`() = runTest { useCase("password", biometricCipher = null) - coVerify(exactly = 0) { wrappedKeyRepository.setBiometricWrappedKey(any()) } + assertEquals(null, accountRepository.getOrNull()?.biometricWrappedArk) } @Test - fun `passes password bytes to derivation`() = runTest { - setupSuccessfulDerivation() - - useCase("myPassword123") - - val passwordSlot = slot() - coVerify { - keyDerivationRepository.deriveKey( - password = capture(passwordSlot), - salt = any(), - parallelism = any() - ) - } - assertTrue(passwordSlot.captured.contentEquals("myPassword123".toByteArray())) + fun `creates account and default vault with supplied name`() = runTest { + useCase("password", vaultName = "My Vault") + + val vault = vaultRepository.observeVaults().first().single() + assertEquals("My Vault", vault.name) } @Test - fun `passes device core count as parallelism`() = runTest { - val kekBytes = ByteArray(32) { it.toByte() } - coEvery { - keyDerivationRepository.deriveKey( - password = any(), - salt = any(), - parallelism = any() - ) - } returns Result.Success(kekBytes) - every { deviceInfoRepository.getNumCors() } returns 12 + fun `uses supplied account display name`() = runTest { + useCase("password", accountDisplayName = "Work") - useCase("password") - - coVerify { - keyDerivationRepository.deriveKey( - password = any(), - salt = any(), - parallelism = 12 - ) - } + assertEquals("Work", accountRepository.getOrNull()?.displayName) } @Test fun `generates different salts for different invocations`() = runTest { - setupSuccessfulDerivation() - useCase("password") - val slot1 = slot() - coVerify { wrappedKeyRepository.setPasswordWrappedKey(capture(slot1)) } - val salt1 = slot1.captured.salt.clone() + val salt1 = accountRepository.getOrNull()!!.passwordWrappedArk.salt.copyOf() useCase("password") - val slot2 = mutableListOf() - coVerify { wrappedKeyRepository.setPasswordWrappedKey(capture(slot2)) } - val salt2 = slot2.last().salt + val salt2 = accountRepository.getOrNull()!!.passwordWrappedArk.salt - // While not guaranteed by SecureRandom, duplicate 16-byte salts are astronomically unlikely assertTrue(!salt1.contentEquals(salt2)) } } diff --git a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt index 463e300a..ed9c6c73 100644 --- a/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt +++ b/core/identity/src/test/kotlin/de/davis/keygo/core/identity/domain/usecase/UnlockWithPasswordUseCaseTest.kt @@ -1,26 +1,20 @@ package de.davis.keygo.core.identity.domain.usecase -import de.davis.keygo.core.identity.domain.model.PasswordWrappedKeyData +import de.davis.keygo.core.identity.FakeAccountRepository +import de.davis.keygo.core.identity.domain.model.Account +import de.davis.keygo.core.identity.domain.model.PasswordWrappedArk import de.davis.keygo.core.identity.domain.model.UnlockError -import de.davis.keygo.core.identity.domain.repository.DeviceInfoRepository -import de.davis.keygo.core.identity.domain.repository.KeyDerivationRepository -import de.davis.keygo.core.identity.domain.repository.WrappedKeyRepository import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.model.AesKey -import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every +import de.davis.keygo.rust.FakeKeyDeriver +import de.davis.keygo.rust.FakeKeyWrapper import io.mockk.mockk import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.test.runTest -import java.security.SecureRandom -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.spec.SecretKeySpec +import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -28,157 +22,79 @@ import kotlin.test.assertTrue class UnlockWithPasswordUseCaseTest { private val session = mockk(relaxed = true) - private val wrappedKeyRepository = mockk() - private val keyDerivationRepository = mockk() - private val deviceInfoRepository = mockk() + private val accountRepository = FakeAccountRepository() + private val keyDeriver = FakeKeyDeriver() + private val keyWrapper = FakeKeyWrapper() private val useCase = UnlockWithPasswordUseCase( session = session, - wrappedKeyRepository = wrappedKeyRepository, - keyDerivationRepository = keyDerivationRepository, - deviceInfoRepository = deviceInfoRepository + accountRepository = accountRepository, + keyDeriver = keyDeriver, + keyWrapper = keyWrapper, ) - private fun generateWrappedKeyData(password: String): Pair { - val dek = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - - val salt = ByteArray(16).also { SecureRandom().nextBytes(it) } - val kekBytes = ByteArray(32).also { SecureRandom().nextBytes(it) } - val kek = SecretKeySpec(kekBytes, "AES") - - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.WRAP_MODE, kek) - val wrappedDek = cipher.wrap(dek) - val iv = cipher.iv - - return PasswordWrappedKeyData( - key = wrappedDek, - keyIV = iv, - salt = salt - ) to kekBytes + private fun seedAccount( + password: String, + accountId: UUID = UUID.randomUUID(), + ark: ByteArray = ByteArray(32) { it.toByte() }, + ): Account { + val salt = keyDeriver.generateSalt() + val kek = keyDeriver.deriveRootKekFromPassword(password, salt) + val wrapped = keyWrapper.wrapAccountRootKey(kek, ark, accountId) + + val account = Account( + id = accountId, + displayName = "Test", + passwordWrappedArk = PasswordWrappedArk( + key = wrapped.ciphertext, + keyIV = wrapped.nonce, + salt = salt, + ), + biometricWrappedArk = null, + ) + accountRepository.seed(account) + return account } @Test - fun `returns WrappedKeyNotFound when repository has no key`() = runTest { - coEvery { wrappedKeyRepository.getPasswordWrappedKey() } returns Result.Failure(Unit) - + fun `returns ActiveAccountNotFound when no account is registered`() = runTest { val result = useCase("password") assertTrue(result.isFailure()) - assertEquals(UnlockError.WrappedKeyNotFound, (result as Result.Failure).error) + assertEquals(UnlockError.ActiveAccountNotFound, result.error) } @Test fun `returns DerivationFailed when key derivation fails`() = runTest { - val (wrappedData, _) = generateWrappedKeyData("password") - coEvery { wrappedKeyRepository.getPasswordWrappedKey() } returns Result.Success(wrappedData) - coEvery { - keyDerivationRepository.deriveKey( - password = any(), - salt = any(), - parallelism = any() - ) - } returns Result.Failure("derivation error") - every { deviceInfoRepository.getNumCors() } returns 4 + seedAccount("password") + keyDeriver.failDerivation = true val result = useCase("password") assertTrue(result.isFailure()) - assertEquals(UnlockError.DerivationFailed, (result as Result.Failure).error) + assertEquals(UnlockError.DerivationFailed, result.error) } @Test fun `returns UnwrappingFailed when wrong password is used`() = runTest { - val (wrappedData, _) = generateWrappedKeyData("password") - val wrongKekBytes = ByteArray(32).also { SecureRandom().nextBytes(it) } - - coEvery { wrappedKeyRepository.getPasswordWrappedKey() } returns Result.Success(wrappedData) - coEvery { - keyDerivationRepository.deriveKey( - password = any(), - salt = any(), - parallelism = any() - ) - } returns Result.Success(wrongKekBytes) - every { deviceInfoRepository.getNumCors() } returns 4 + seedAccount("password") val result = useCase("wrong-password") assertTrue(result.isFailure()) - assertEquals(UnlockError.UnwrappingFailed, (result as Result.Failure).error) + assertEquals(UnlockError.UnwrappingFailed, result.error) } @Test fun `returns Success and starts session with correct password`() = runTest { - val (wrappedData, kekBytes) = generateWrappedKeyData("password") - - coEvery { wrappedKeyRepository.getPasswordWrappedKey() } returns Result.Success(wrappedData) - coEvery { - keyDerivationRepository.deriveKey( - password = any(), - salt = any(), - parallelism = any() - ) - } returns Result.Success(kekBytes) - every { deviceInfoRepository.getNumCors() } returns 4 + val ark = ByteArray(32) { (it + 1).toByte() } + seedAccount("password", ark = ark) val result = useCase("password") assertTrue(result.isSuccess()) val dekSlot = slot() verify { session.startSession(capture(dekSlot)) } - assertTrue(dekSlot.captured.key.encoded.size == 32) - } - - @Test - fun `passes correct salt to key derivation`() = runTest { - val (wrappedData, kekBytes) = generateWrappedKeyData("password") - - coEvery { wrappedKeyRepository.getPasswordWrappedKey() } returns Result.Success(wrappedData) - coEvery { - keyDerivationRepository.deriveKey( - password = any(), - salt = any(), - parallelism = any() - ) - } returns Result.Success(kekBytes) - every { deviceInfoRepository.getNumCors() } returns 4 - - useCase("password") - - val saltSlot = slot() - coVerify { - keyDerivationRepository.deriveKey( - password = any(), - salt = capture(saltSlot), - parallelism = any() - ) - } - assertTrue(saltSlot.captured.contentEquals(wrappedData.salt)) - } - - @Test - fun `passes device core count to key derivation`() = runTest { - val (wrappedData, kekBytes) = generateWrappedKeyData("password") - - coEvery { wrappedKeyRepository.getPasswordWrappedKey() } returns Result.Success(wrappedData) - coEvery { - keyDerivationRepository.deriveKey( - password = any(), - salt = any(), - parallelism = any() - ) - } returns Result.Success(kekBytes) - every { deviceInfoRepository.getNumCors() } returns 8 - - useCase("password") - - coVerify { - keyDerivationRepository.deriveKey( - password = any(), - salt = any(), - parallelism = 8 - ) - } + assertTrue(dekSlot.captured.key.encoded.contentEquals(ark)) } } diff --git a/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt b/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt new file mode 100644 index 00000000..31e0694e --- /dev/null +++ b/core/identity/src/testFixtures/kotlin/de/davis/keygo/core/identity/FakeAccountRepository.kt @@ -0,0 +1,24 @@ +package de.davis.keygo.core.identity + +import de.davis.keygo.core.identity.domain.model.Account +import de.davis.keygo.core.identity.domain.repository.AccountRepository +import de.davis.keygo.core.util.Result + +class FakeAccountRepository : AccountRepository { + + private var account: Account? = null + + var setFails: Boolean = false + + fun seed(account: Account) { + this.account = account + } + + override suspend fun getOrNull(): Account? = account + + override suspend fun set(account: Account): Result { + if (setFails) return Result.Failure(Unit) + this.account = account + return Result.Success(Unit) + } +} diff --git a/core/item/build.gradle.kts b/core/item/build.gradle.kts index 7dcc90f2..8706cf38 100644 --- a/core/item/build.gradle.kts +++ b/core/item/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) + alias(libs.plugins.google.protobuf) alias(libs.plugins.androidx.room) alias(libs.plugins.google.ksp) alias(libs.plugins.kotlin.compose) @@ -28,6 +29,10 @@ android { buildFeatures { compose = true } + + testFixtures { + enable = true + } } kotlin { @@ -48,7 +53,7 @@ dependencies { implementation(projects.automation) ksp(projects.automationProcessor) - implementation(projects.core.security) + api(projects.core.util) implementation(libs.gosimple.nbvcxz) @@ -57,6 +62,10 @@ dependencies { implementation(libs.androidx.material.icons.extended) implementation(libs.androidx.material3) + // Datastore + implementation(libs.androidx.datastore) + implementation(libs.google.protobuf.kotlin.lite) + // Koin DI implementation(project.dependencies.platform(libs.koin.bom)) implementation(libs.koin.androidx.compose) @@ -65,6 +74,15 @@ dependencies { testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.io.mockk) + + testFixturesImplementation(libs.kotlinx.coroutines.core) + testFixturesApi(projects.core.util) + testFixturesImplementation(projects.core.security) + testFixturesImplementation(projects.rust) + testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) + testFixturesImplementation(libs.androidx.compose.runtime) { + because("https://issuetracker.google.com/issues/259523353#comment32") + } } room { @@ -73,4 +91,27 @@ room { ksp { arg("automation.android_namespace", "de.davis.keygo.core.item") -} \ No newline at end of file +} + +protobuf { + protoc { + artifact = libs.google.protobuf.protoc.get().toString() + } + + generateProtoTasks { + all().forEach { task -> + if (task.name.contains("TestFixtures", ignoreCase = true)) { + task.enabled = false + return@forEach + } + task.builtins { + create("java") { + option("lite") + } + create("kotlin") { + option("lite") + } + } + } + } +} diff --git a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json index 3b20974d..85d6fafd 100644 --- a/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json +++ b/core/item/schemas/de.davis.keygo.core.item.data.local.datasource.ItemDatabase/1.json @@ -2,16 +2,16 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "74ee7b1a530df594091711f23a9564f2", + "identityHash": "faeacb95b88a936e7e7ca72b9eb13e78", "entities": [ { - "tableName": "VaultItemEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `note` TEXT, `encrypted_data` BLOB NOT NULL, `itemType` TEXT NOT NULL, `pinned` INTEGER NOT NULL)", + "tableName": "vault", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `icon` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `wrappedKey` BLOB NOT NULL, `keyNonce` BLOB NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", - "affinity": "INTEGER", + "affinity": "BLOB", "notNull": true }, { @@ -21,16 +21,64 @@ "notNull": true }, { - "fieldPath": "note", - "columnName": "note", - "affinity": "TEXT" + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyInformation.wrappedKey", + "columnName": "wrappedKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "keyInformation.keyNonce", + "columnName": "keyNonce", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `vault_id` BLOB NOT NULL, `name` TEXT NOT NULL, `note` TEXT, `itemType` TEXT NOT NULL, `pinned` INTEGER NOT NULL, `wrappedKey` BLOB NOT NULL, `keyNonce` BLOB NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`vault_id`) REFERENCES `vault`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true }, { - "fieldPath": "encryptedData", - "columnName": "encrypted_data", + "fieldPath": "vaultId", + "columnName": "vault_id", "affinity": "BLOB", "notNull": true }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT" + }, { "fieldPath": "itemType", "columnName": "itemType", @@ -42,23 +90,59 @@ "columnName": "pinned", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "keyInformation.wrappedKey", + "columnName": "wrappedKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "keyInformation.keyNonce", + "columnName": "keyNonce", + "affinity": "BLOB", + "notNull": true } ], "primaryKey": { - "autoGenerate": true, + "autoGenerate": false, "columnNames": [ "id" ] - } + }, + "indices": [ + { + "name": "index_item_vault_id", + "unique": false, + "columnNames": [ + "vault_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_item_vault_id` ON `${TABLE_NAME}` (`vault_id`)" + } + ], + "foreignKeys": [ + { + "table": "vault", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "vault_id" + ], + "referencedColumns": [ + "id" + ] + } + ] }, { - "tableName": "PasswordEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `username` TEXT, `score` TEXT NOT NULL, `totp_secret` BLOB, `vault_item_id` INTEGER NOT NULL, FOREIGN KEY(`vault_item_id`) REFERENCES `VaultItemEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "tableName": "password", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `username` TEXT, `score` TEXT NOT NULL, `password` BLOB NOT NULL, `totp_secret` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `item`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", "columnName": "id", - "affinity": "INTEGER", + "affinity": "BLOB", "notNull": true }, { @@ -72,42 +156,31 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "BLOB", + "notNull": true + }, { "fieldPath": "totpSecret", "columnName": "totp_secret", "affinity": "BLOB" - }, - { - "fieldPath": "vaultItemId", - "columnName": "vault_item_id", - "affinity": "INTEGER", - "notNull": true } ], "primaryKey": { - "autoGenerate": true, + "autoGenerate": false, "columnNames": [ "id" ] }, - "indices": [ - { - "name": "index_PasswordEntity_vault_item_id", - "unique": true, - "columnNames": [ - "vault_item_id" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PasswordEntity_vault_item_id` ON `${TABLE_NAME}` (`vault_item_id`)" - } - ], "foreignKeys": [ { - "table": "VaultItemEntity", + "table": "item", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ - "vault_item_id" + "id" ], "referencedColumns": [ "id" @@ -116,13 +189,13 @@ ] }, { - "tableName": "DomainInfoEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`password_id` INTEGER NOT NULL, `value` TEXT NOT NULL, `eTLD1` TEXT, PRIMARY KEY(`password_id`, `value`), FOREIGN KEY(`password_id`) REFERENCES `PasswordEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "tableName": "domain_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`password_id` BLOB NOT NULL, `value` TEXT NOT NULL, `eTLD1` TEXT, PRIMARY KEY(`password_id`, `value`), FOREIGN KEY(`password_id`) REFERENCES `password`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "passwordId", "columnName": "password_id", - "affinity": "INTEGER", + "affinity": "BLOB", "notNull": true }, { @@ -146,18 +219,18 @@ }, "indices": [ { - "name": "index_DomainInfoEntity_eTLD1", + "name": "index_domain_info_eTLD1", "unique": false, "columnNames": [ "eTLD1" ], "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_DomainInfoEntity_eTLD1` ON `${TABLE_NAME}` (`eTLD1`)" + "createSql": "CREATE INDEX IF NOT EXISTS `index_domain_info_eTLD1` ON `${TABLE_NAME}` (`eTLD1`)" } ], "foreignKeys": [ { - "table": "PasswordEntity", + "table": "password", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ @@ -170,8 +243,8 @@ ] }, { - "tableName": "PasskeyEntity", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`credential_id` BLOB NOT NULL, `rp` TEXT NOT NULL, `private_key` BLOB NOT NULL, `password_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `display_name` TEXT NOT NULL, PRIMARY KEY(`credential_id`), FOREIGN KEY(`password_id`) REFERENCES `PasswordEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "tableName": "passkey", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`credential_id` BLOB NOT NULL, `rp` TEXT NOT NULL, `private_key` BLOB NOT NULL, `password_id` BLOB NOT NULL, `name` TEXT NOT NULL, `display_name` TEXT NOT NULL, PRIMARY KEY(`credential_id`), FOREIGN KEY(`password_id`) REFERENCES `password`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "credentialId", @@ -194,7 +267,7 @@ { "fieldPath": "passwordId", "columnName": "password_id", - "affinity": "INTEGER", + "affinity": "BLOB", "notNull": true }, { @@ -218,27 +291,27 @@ }, "indices": [ { - "name": "index_PasskeyEntity_password_id", + "name": "index_passkey_password_id", "unique": false, "columnNames": [ "password_id" ], "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_PasskeyEntity_password_id` ON `${TABLE_NAME}` (`password_id`)" + "createSql": "CREATE INDEX IF NOT EXISTS `index_passkey_password_id` ON `${TABLE_NAME}` (`password_id`)" }, { - "name": "index_PasskeyEntity_rp", + "name": "index_passkey_rp", "unique": false, "columnNames": [ "rp" ], "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_PasskeyEntity_rp` ON `${TABLE_NAME}` (`rp`)" + "createSql": "CREATE INDEX IF NOT EXISTS `index_passkey_rp` ON `${TABLE_NAME}` (`rp`)" } ], "foreignKeys": [ { - "table": "PasswordEntity", + "table": "password", "onDelete": "CASCADE", "onUpdate": "NO ACTION", "columns": [ @@ -253,7 +326,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '74ee7b1a530df594091711f23a9564f2')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'faeacb95b88a936e7e7ca72b9eb13e78')" ] } } \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/VaultContextSerializer.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/VaultContextSerializer.kt new file mode 100644 index 00000000..0ccc9fbd --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/VaultContextSerializer.kt @@ -0,0 +1,19 @@ +package de.davis.keygo.core.item.data.local + +import androidx.datastore.core.Serializer +import de.davis.keygo.core.item.data.local.model.ProtoVaultContextRecord +import java.io.InputStream +import java.io.OutputStream + +internal object VaultContextSerializer : Serializer { + + override val defaultValue: ProtoVaultContextRecord = + ProtoVaultContextRecord.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): ProtoVaultContextRecord = + ProtoVaultContextRecord.parseFrom(input) + + override suspend fun writeTo(t: ProtoVaultContextRecord, output: OutputStream) { + t.writeTo(output) + } +} \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/DomainInfoDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/DomainInfoDao.kt index 7c9f1141..9bce77aa 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/DomainInfoDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/DomainInfoDao.kt @@ -5,21 +5,22 @@ import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity +import de.davis.keygo.core.item.domain.alias.ItemId @Dao internal abstract class DomainInfoDao { - @Query("DELETE FROM DomainInfoEntity WHERE password_id = :passwordId AND (value IS NULL OR value NOT IN (:except))") + @Query("DELETE FROM domain_info WHERE password_id = :passwordId AND (value IS NULL OR value NOT IN (:except))") protected abstract suspend fun deleteAllDomainsForPassword( - passwordId: Long, + passwordId: ItemId, except: Set = emptySet() ) @Upsert - abstract suspend fun upsertAll(domains: Set): List + abstract suspend fun upsertAll(domains: Set) @Transaction - open suspend fun syncForPassword(passwordId: Long, domains: Set) { + open suspend fun syncForPassword(passwordId: ItemId, domains: Set) { if (domains.isEmpty()) { deleteAllDomainsForPassword(passwordId) return @@ -29,4 +30,4 @@ internal abstract class DomainInfoDao { upsertAll(adjusted) deleteAllDomainsForPassword(passwordId, adjusted.map { it.value }.toSet()) } -} \ No newline at end of file +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt new file mode 100644 index 00000000..f00008dd --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/ItemDao.kt @@ -0,0 +1,84 @@ +package de.davis.keygo.core.item.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Upsert +import de.davis.keygo.core.item.data.local.entity.ItemEntity +import de.davis.keygo.core.item.data.local.pojo.LightweightItem +import de.davis.keygo.core.item.data.local.pojo.LightweightItemSearchResult +import de.davis.keygo.core.item.data.local.pojo.MovableItemPojo +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import kotlinx.coroutines.flow.Flow + +@Dao +internal interface ItemDao { + + @Upsert + suspend fun upsert(item: ItemEntity): Long + + @Query("DELETE FROM item WHERE id = :id") + suspend fun delete(id: ItemId) + + @Query("SELECT name FROM item WHERE id = :id") + suspend fun getNameById(id: ItemId): String? + + @Query("SELECT EXISTS(SELECT 1 FROM item WHERE name = :name AND (:excludeId IS NULL OR id != :excludeId) AND (:vaultId IS NULL OR vault_id = :vaultId))") + suspend fun existsName( + name: String, + excludeId: ItemId? = null, + vaultId: VaultId? = null + ): Boolean + + @Query("SELECT * FROM item WHERE id = :id") + suspend fun getItemById(id: ItemId): ItemEntity? + + @Query( + """ + SELECT i.id, i.name, i.itemType, i.pinned, + (name LIKE '%' || :query || '%') AS matchedName, + (note LIKE '%' || :query || '%') AS matchedNote + FROM item i + WHERE (:itemType IS NULL OR itemType = :itemType) + AND (name LIKE '%' || :query || '%' OR COALESCE(note, '') LIKE '%' || :query || '%') + """ + ) + suspend fun searchItem( + query: String, + itemType: VaultItemType? = null, + ): List + + @Query("UPDATE item SET pinned = :pinned WHERE id = :id") + suspend fun setPinned(id: ItemId, pinned: Boolean) + + @Query("SELECT i.id, i.name, i.itemType, i.pinned FROM item i WHERE (:vaultId IS NULL OR vault_id = :vaultId)") + fun observeLiteItems(vaultId: VaultId? = null): Flow> + + @Query("SELECT id, wrappedKey, keyNonce FROM item WHERE vault_id = :vaultId") + suspend fun getMovableItemsByVault(vaultId: VaultId): List + + @Query( + "UPDATE item SET vault_id = :newVaultId, wrappedKey = :wrappedKey, keyNonce = :keyNonce " + + "WHERE id = :id" + ) + suspend fun moveItem( + id: ItemId, + newVaultId: VaultId, + wrappedKey: ByteArray, + keyNonce: ByteArray, + ) + + @Transaction + suspend fun moveItemsToVault(items: List, newVaultId: VaultId) { + items.forEach { + moveItem( + id = it.id, + newVaultId = newVaultId, + wrappedKey = it.keyInformation.wrappedKey, + keyNonce = it.keyInformation.keyNonce, + ) + } + } +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt index ddc5521e..5e1ac3a3 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasskeyDao.kt @@ -12,19 +12,19 @@ internal interface PasskeyDao { @Insert suspend fun insertPasskey(passkey: PasskeyEntity) - @Query("SELECT * FROM PasskeyEntity WHERE credential_id = :credentialId") + @Query("SELECT * FROM passkey WHERE credential_id = :credentialId") suspend fun getPasskey(credentialId: ByteArray): PasskeyEntity? - @Query("SELECT EXISTS (SELECT 1 FROM PasskeyEntity WHERE credential_id in (:credentialIds))") + @Query("SELECT EXISTS (SELECT 1 FROM passkey WHERE credential_id IN (:credentialIds))") suspend fun doesCredentialIdsExist(credentialIds: Set): Boolean @Query( """ - SELECT passkey.credential_id, passkey.name, passkey.display_name, password.username as password_username, vault.name as vault_name - FROM PasskeyEntity passkey - INNER JOIN PasswordEntity password ON passkey.password_id == password.id - INNER JOIN VaultItemEntity vault ON password.vault_item_id == vault.id - WHERE passkey.rp = :rpId + SELECT pk.credential_id, pk.name, pk.display_name, p.username AS password_username, i.name AS vault_name + FROM passkey pk + INNER JOIN password p ON pk.password_id = p.id + INNER JOIN item i ON p.id = i.id + WHERE pk.rp = :rpId """ ) suspend fun getPasskeysForRP(rpId: String): List diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasswordDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasswordDao.kt index 6b5ad48f..d6f63f27 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasswordDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/PasswordDao.kt @@ -9,30 +9,31 @@ import de.davis.keygo.core.item.data.local.pojo.LightweightPassword import de.davis.keygo.core.item.data.local.pojo.PasswordScoreEntry import de.davis.keygo.core.item.data.local.pojo.VaultPassword import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId import kotlinx.coroutines.flow.Flow @Dao internal interface PasswordDao { @Transaction - @Query("SELECT * FROM PasswordEntity") + @Query("SELECT * FROM password") fun getAllPasswords(): Flow> @Transaction - @Query("SELECT * FROM PasswordEntity WHERE vault_item_id = :vaultId") - fun observeVaultPassword(vaultId: ItemId): Flow + @Query("SELECT * FROM password WHERE id = :id") + fun observeVaultPassword(id: ItemId): Flow @Transaction @Query( """ - SELECT vault.id vault_item_id, vault.name name, vault.pinned, password.id password_id, password.username username - FROM VaultItemEntity vault - JOIN PasswordEntity password ON vault.id = password.vault_item_id - WHERE (NOT :requireTotp OR password.totp_secret IS NOT NULL) + SELECT i.id, i.name, i.pinned, p.username + FROM item i + JOIN password p ON i.id = p.id + WHERE (NOT :requireTotp OR p.totp_secret IS NOT NULL) AND EXISTS ( - SELECT 1 FROM DomainInfoEntity domain - WHERE domain.password_id = password.id - AND domain.eTLD1 in (:etld1s) COLLATE NOCASE + SELECT 1 FROM domain_info d + WHERE d.password_id = p.id + AND d.eTLD1 IN (:etld1s) COLLATE NOCASE ) LIMIT :limit """ @@ -44,15 +45,16 @@ internal interface PasswordDao { ): List @Transaction - @Query("SELECT * FROM PasswordEntity WHERE vault_item_id = :vaultId") - suspend fun getVaultPassword(vaultId: ItemId): VaultPassword? + @Query("SELECT * FROM password WHERE id = :id") + suspend fun getVaultPassword(id: ItemId): VaultPassword? - @Query("SELECT vault_item_id, score FROM PasswordEntity") - fun observePasswordScores(): Flow> + @Transaction + @Query("SELECT * FROM password WHERE id IN (SELECT id FROM item WHERE vault_id = :vaultId)") + suspend fun getPasswordsByVault(vaultId: VaultId): List - @Query("SELECT id FROM PasswordEntity WHERE vault_item_id = :vaultId") - suspend fun getPasswordIdByVaultId(vaultId: ItemId): ItemId? + @Query("SELECT id, score FROM password") + fun observePasswordScores(): Flow> @Upsert - suspend fun upsert(password: PasswordEntity): ItemId -} \ No newline at end of file + suspend fun upsert(password: PasswordEntity) +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/VaultDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/VaultDao.kt index fa157776..d1fa9ad4 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/VaultDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/VaultDao.kt @@ -1,49 +1,53 @@ package de.davis.keygo.core.item.data.local.dao import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Upsert -import de.davis.keygo.core.item.data.local.entity.VaultItemEntity -import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItem -import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItemSearchResult -import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import androidx.room.Update +import de.davis.keygo.core.item.data.local.entity.KeyInformation +import de.davis.keygo.core.item.data.local.entity.VaultEntity +import de.davis.keygo.core.item.data.local.pojo.VaultMetadata +import de.davis.keygo.core.item.data.local.pojo.VaultUpdater +import de.davis.keygo.core.item.domain.alias.VaultId import kotlinx.coroutines.flow.Flow @Dao internal interface VaultDao { - @Upsert - suspend fun upsert(vaultItem: VaultItemEntity): ItemId + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vault: VaultEntity) - @Query("DELETE FROM VaultItemEntity WHERE id = :id") - suspend fun delete(id: ItemId) + @Update(entity = VaultEntity::class) + suspend fun update(vaultUpdater: VaultUpdater) - @Query("SELECT name FROM VaultItemEntity WHERE id = :itemId") - suspend fun getNameById(itemId: ItemId): String? + @Query("DELETE FROM vault WHERE id = :id") + suspend fun delete(id: VaultId) - @Query("SELECT EXISTS(SELECT 1 FROM VaultItemEntity WHERE name = :name AND (:excludeId IS NULL OR id != :excludeId))") - suspend fun existsName(name: String, excludeId: ItemId? = null): Boolean - - @Query("SELECT * FROM VaultItemEntity WHERE id = :id") - suspend fun getVaultItemById(id: ItemId): VaultItemEntity? + @Query("SELECT wrappedKey, keyNonce FROM vault WHERE id = :id") + suspend fun getKeyInfoById(id: VaultId): KeyInformation? @Query( """ - SELECT v.id, v.name, v.itemType, v.pinned, (name LIKE '%' || :query || '%') AS matchedName, (note LIKE '%' || :query || '%') AS matchedNote - FROM VaultItemEntity v - WHERE (:itemType IS NULL OR itemType = :itemType) - AND (name LIKE '%' || :query || '%' OR COALESCE(note, '') LIKE '%' || :query || '%') + SELECT vault.id as vaultId, vault.name, vault.icon, vault.created_at as createdAt, COUNT(item.id) as count + FROM vault + LEFT JOIN item ON vault.id = item.vault_id + GROUP BY vault.id """ ) - suspend fun searchVaultItem( - query: String, - itemType: VaultItemType? = null - ): List + fun observeAllVaultMetadata(): Flow> - @Query("UPDATE VaultItemEntity SET pinned = :pinned WHERE id = :id") - suspend fun setPinned(id: ItemId, pinned: Boolean) + @Query( + """ + SELECT vault.id as vaultId, vault.name, vault.icon, vault.created_at as createdAt, COUNT(item.id) as count + FROM vault + LEFT JOIN item ON vault.id = item.vault_id + WHERE vault.id = :id + GROUP BY vault.id + """ + ) + suspend fun getVaultMetadata(id: VaultId): VaultMetadata? - @Query("SELECT v.id, v.name, v.itemType, v.pinned FROM VaultItemEntity v") - fun observeLiteVaultItems(): Flow> + @Query("SELECT id FROM vault WHERE id != :exclude ORDER BY created_at DESC LIMIT 1") + suspend fun lastCreatedVaultId(exclude: VaultId): VaultId? } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt index 33055da4..d8604657 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/datasource/ItemDatabase.kt @@ -7,30 +7,35 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import de.davis.keygo.core.item.data.local.converter.SecretDataConverter import de.davis.keygo.core.item.data.local.dao.DomainInfoDao +import de.davis.keygo.core.item.data.local.dao.ItemDao import de.davis.keygo.core.item.data.local.dao.PasskeyDao import de.davis.keygo.core.item.data.local.dao.PasswordDao import de.davis.keygo.core.item.data.local.dao.VaultDao import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity +import de.davis.keygo.core.item.data.local.entity.ItemEntity import de.davis.keygo.core.item.data.local.entity.PasskeyEntity import de.davis.keygo.core.item.data.local.entity.PasswordEntity -import de.davis.keygo.core.item.data.local.entity.VaultItemEntity +import de.davis.keygo.core.item.data.local.entity.VaultEntity import org.koin.core.annotation.Module import org.koin.core.annotation.Single @Database( entities = [ - VaultItemEntity::class, + VaultEntity::class, + ItemEntity::class, PasswordEntity::class, DomainInfoEntity::class, - PasskeyEntity::class + PasskeyEntity::class, ], - version = 1 + version = 1, ) @TypeConverters(SecretDataConverter::class) internal abstract class ItemDatabase : RoomDatabase() { abstract fun vaultDao(): VaultDao + abstract fun itemDao(): ItemDao + abstract fun passwordDao(): PasswordDao abstract fun domainInfoDao(): DomainInfoDao @@ -46,12 +51,15 @@ internal class DatabaseModule { Room.databaseBuilder( context, ItemDatabase::class.java, - "secure_element_database" - ).fallbackToDestructiveMigration(false).build() + "secure_element_database", + ).build() @Single fun provideVaultDao(db: ItemDatabase) = db.vaultDao() + @Single + fun provideItemDao(db: ItemDatabase) = db.itemDao() + @Single fun providePasswordDao(db: ItemDatabase) = db.passwordDao() @@ -60,4 +68,4 @@ internal class DatabaseModule { @Single fun providePasskeyDao(db: ItemDatabase) = db.passkeyDao() -} \ No newline at end of file +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/DomainInfoEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/DomainInfoEntity.kt index eef64849..f3778e05 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/DomainInfoEntity.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/DomainInfoEntity.kt @@ -4,8 +4,10 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index +import de.davis.keygo.core.item.domain.alias.ItemId @Entity( + tableName = "domain_info", primaryKeys = ["password_id", "value"], foreignKeys = [ ForeignKey( @@ -15,13 +17,11 @@ import androidx.room.Index onDelete = ForeignKey.CASCADE, ) ], - indices = [ - Index("eTLD1"), - ] + indices = [Index("eTLD1")], ) internal data class DomainInfoEntity( @ColumnInfo("password_id") - val passwordId: Long, + val passwordId: ItemId, val value: String, val eTLD1: String?, ) \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/ItemEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/ItemEntity.kt new file mode 100644 index 00000000..db338a63 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/ItemEntity.kt @@ -0,0 +1,37 @@ +package de.davis.keygo.core.item.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.generated.domain.model.VaultItemType + +@Entity( + tableName = "item", + foreignKeys = [ + ForeignKey( + entity = VaultEntity::class, + parentColumns = ["id"], + childColumns = ["vault_id"], + onDelete = ForeignKey.CASCADE, + ) + ], + indices = [Index("vault_id")], +) +internal data class ItemEntity( + @PrimaryKey + val id: ItemId, + @ColumnInfo(name = "vault_id") + val vaultId: VaultId, + + val name: String, + val note: String?, + val itemType: VaultItemType, + val pinned: Boolean, + + @Embedded val keyInformation: KeyInformation, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/KeyInformation.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/KeyInformation.kt new file mode 100644 index 00000000..f5800e2e --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/KeyInformation.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.core.item.data.local.entity + +internal class KeyInformation( + val wrappedKey: ByteArray, + val keyNonce: ByteArray, +) \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasskeyEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasskeyEntity.kt index 8d3aeb49..d3644230 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasskeyEntity.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasskeyEntity.kt @@ -9,6 +9,7 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.SecretData @Entity( + tableName = "passkey", foreignKeys = [ ForeignKey( entity = PasswordEntity::class, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasswordEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasswordEntity.kt index da40f028..495d2717 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasswordEntity.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/PasswordEntity.kt @@ -3,32 +3,31 @@ package de.davis.keygo.core.item.data.local.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.Index import androidx.room.PrimaryKey import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Password import de.davis.keygo.core.item.domain.model.SecretData +// The primary key is shared with ItemEntity — one ItemId identifies both the base item row and +// this password row. This models the "is-a" relationship at the DB level (joined-table +// inheritance): there is no separate password-specific ID. @Entity( + tableName = "password", foreignKeys = [ ForeignKey( - entity = VaultItemEntity::class, + entity = ItemEntity::class, parentColumns = ["id"], - childColumns = ["vault_item_id"], - onDelete = ForeignKey.CASCADE + childColumns = ["id"], + onDelete = ForeignKey.CASCADE, ) ], - indices = [ - Index(value = ["vault_item_id"], unique = true) - ], ) internal data class PasswordEntity( - @PrimaryKey(autoGenerate = true) + @PrimaryKey val id: ItemId, val username: String?, val score: Password.Score, + val password: SecretData, @ColumnInfo(name = "totp_secret") val totpSecret: SecretData?, - @ColumnInfo(name = "vault_item_id") - val vaultItemId: ItemId, ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/VaultEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/VaultEntity.kt new file mode 100644 index 00000000..3f8c4023 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/VaultEntity.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.core.item.data.local.entity + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.PrimaryKey +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Vault + +@Entity(tableName = "vault") +internal data class VaultEntity( + @PrimaryKey + val id: VaultId, + val name: String, + val icon: Vault.Icon, + @ColumnInfo(name = "created_at") + val createdAt: Long, + + @Embedded + val keyInformation: KeyInformation, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/VaultItemEntity.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/VaultItemEntity.kt deleted file mode 100644 index f4a9a4bc..00000000 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/entity/VaultItemEntity.kt +++ /dev/null @@ -1,20 +0,0 @@ -package de.davis.keygo.core.item.data.local.entity - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.model.SecretData -import de.davis.keygo.core.item.generated.domain.model.VaultItemType - -@Entity -internal data class VaultItemEntity( - @PrimaryKey(autoGenerate = true) - val id: ItemId, - val name: String, - val note: String?, - @ColumnInfo(name = "encrypted_data") - val encryptedData: SecretData, - val itemType: VaultItemType, - val pinned: Boolean, -) \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItem.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItem.kt similarity index 64% rename from core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItem.kt rename to core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItem.kt index 195e7af3..6d11ec5e 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItem.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItem.kt @@ -1,9 +1,10 @@ package de.davis.keygo.core.item.data.local.pojo +import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.generated.domain.model.VaultItemType -internal data class LightweightVaultItem( - val id: Long, +internal data class LightweightItem( + val id: ItemId, val name: String, val itemType: VaultItemType, val pinned: Boolean, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItemSearchResult.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt similarity index 68% rename from core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItemSearchResult.kt rename to core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt index f7444d54..8f811d28 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightVaultItemSearchResult.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightItemSearchResult.kt @@ -1,9 +1,10 @@ package de.davis.keygo.core.item.data.local.pojo +import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.generated.domain.model.VaultItemType -internal data class LightweightVaultItemSearchResult( - val id: Long, +internal data class LightweightItemSearchResult( + val id: ItemId, val name: String, val itemType: VaultItemType, val matchedName: Boolean, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightPassword.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightPassword.kt index 61664237..c603ee74 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightPassword.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/LightweightPassword.kt @@ -1,22 +1,18 @@ package de.davis.keygo.core.item.data.local.pojo -import androidx.room.ColumnInfo import androidx.room.Relation import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity import de.davis.keygo.core.item.domain.alias.ItemId internal data class LightweightPassword( - @ColumnInfo("vault_item_id") - val vaultItemId: ItemId, - @ColumnInfo("password_id") - val passwordId: ItemId, + val id: ItemId, val username: String?, val name: String, val pinned: Boolean, @Relation( - parentColumn = "password_id", + parentColumn = "id", entityColumn = "password_id" ) val domains: List -) \ No newline at end of file +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/MovableItemPojo.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/MovableItemPojo.kt new file mode 100644 index 00000000..364240e0 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/MovableItemPojo.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.core.item.data.local.pojo + +import androidx.room.Embedded +import de.davis.keygo.core.item.data.local.entity.KeyInformation +import de.davis.keygo.core.item.domain.alias.ItemId + +internal data class MovableItemPojo( + val id: ItemId, + @Embedded + val keyInformation: KeyInformation, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/PasswordScoreEntry.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/PasswordScoreEntry.kt index 9df047ab..e8eace33 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/PasswordScoreEntry.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/PasswordScoreEntry.kt @@ -1,11 +1,9 @@ package de.davis.keygo.core.item.data.local.pojo -import androidx.room.ColumnInfo import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Password internal data class PasswordScoreEntry( - @ColumnInfo(name = "vault_item_id") - val vaultItemId: ItemId, + val id: ItemId, val score: Password.Score, ) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultMetadata.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultMetadata.kt new file mode 100644 index 00000000..5c7ed372 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultMetadata.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.core.item.data.local.pojo + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Vault + +internal data class VaultMetadata( + val vaultId: VaultId, + val name: String, + val icon: Vault.Icon, + val count: Int, + val createdAt: Long, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultPassword.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultPassword.kt index a82e18f8..2f244b8c 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultPassword.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultPassword.kt @@ -3,19 +3,20 @@ package de.davis.keygo.core.item.data.local.pojo import androidx.room.Embedded import androidx.room.Relation import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity +import de.davis.keygo.core.item.data.local.entity.ItemEntity import de.davis.keygo.core.item.data.local.entity.PasskeyEntity import de.davis.keygo.core.item.data.local.entity.PasswordEntity -import de.davis.keygo.core.item.data.local.entity.VaultItemEntity internal data class VaultPassword( @Embedded val passwordEntity: PasswordEntity, + // PasswordEntity.id == ItemEntity.id (shared primary key — "is-a" relationship). @Relation( - parentColumn = "vault_item_id", + parentColumn = "id", entityColumn = "id", ) - val vaultItemEntity: VaultItemEntity, + val itemEntity: ItemEntity, @Relation( parentColumn = "id", diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultUpdater.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultUpdater.kt new file mode 100644 index 00000000..707afd58 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/VaultUpdater.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.core.item.data.local.pojo + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Vault + +internal data class VaultUpdater( + val id: VaultId, + val name: String, + val icon: Vault.Icon, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainInfoMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainInfoMapper.kt index b7019fa5..9d07ed6a 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainInfoMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainInfoMapper.kt @@ -2,16 +2,7 @@ package de.davis.keygo.core.item.data.maper import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.Password -internal fun Password.toDataDomainInfos(passwordId: ItemId = id): Set = - domainInfos.map { - it.toData(passwordId = passwordId) - }.toSet() - -internal fun DomainInfo.toData(passwordId: Long): DomainInfoEntity = DomainInfoEntity( - passwordId = passwordId, - value = value, - eTLD1 = eTLD1, -) \ No newline at end of file +internal fun Password.toDataDomainInfos(passwordId: ItemId): Set = + domainInfos.map { it.toData(passwordId) }.toSet() \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainMapper.kt index da2a09bc..b603c2e7 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/DomainMapper.kt @@ -1,9 +1,10 @@ package de.davis.keygo.core.item.data.maper import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity +import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.DomainInfo -internal fun DomainInfo.toData() = DomainInfoEntity( +internal fun DomainInfo.toData(passwordId: ItemId) = DomainInfoEntity( passwordId = passwordId, value = value, eTLD1 = eTLD1, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/ItemMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/ItemMapper.kt new file mode 100644 index 00000000..18338106 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/ItemMapper.kt @@ -0,0 +1,42 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.data.local.entity.ItemEntity +import de.davis.keygo.core.item.data.local.pojo.LightweightItem +import de.davis.keygo.core.item.data.local.pojo.LightweightItemSearchResult +import de.davis.keygo.core.item.data.local.pojo.MovableItemPojo +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.item.domain.model.MovableItem +import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult + +internal fun Item.toData() = ItemEntity( + id = id, + vaultId = vaultId, + name = name, + note = note, + itemType = itemType, + pinned = pinned, + + keyInformation = keyInformation.toEntity(), +) + +internal fun LightweightItem.toDomain() = LiteItem.Concrete( + id = id, + name = name, + itemType = itemType, + pinned = pinned, +) + +internal fun LightweightItemSearchResult.toDomain() = LiteItemSearchResult( + id = id, + name = name, + itemType = itemType, + matchedName = matchedName, + matchedNote = matchedNote, + pinned = pinned, +) + +internal fun MovableItemPojo.toDomain() = MovableItem( + id = id, + keyInformation = keyInformation.toDomain(), +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/KeyInformationMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/KeyInformationMapper.kt new file mode 100644 index 00000000..f0de7372 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/KeyInformationMapper.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.data.local.entity.KeyInformation +import de.davis.keygo.core.item.domain.model.KeyInformation as DomainKeyInformation + +internal fun DomainKeyInformation.toEntity(): KeyInformation = KeyInformation( + wrappedKey = wrappedKey, + keyNonce = keyNonce +) + +internal fun KeyInformation.toDomain(): DomainKeyInformation = DomainKeyInformation( + wrappedKey = wrappedKey, + keyNonce = keyNonce +) \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/PasswordMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/PasswordMapper.kt index f719fcec..6e68a3a9 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/PasswordMapper.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/PasswordMapper.kt @@ -4,41 +4,39 @@ import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity import de.davis.keygo.core.item.data.local.entity.PasswordEntity import de.davis.keygo.core.item.data.local.pojo.LightweightPassword import de.davis.keygo.core.item.data.local.pojo.VaultPassword -import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.model.Password import de.davis.keygo.core.item.domain.model.lite.LitePassword -internal fun Password.toData(vaultItemId: ItemId = this.vaultItemId): PasswordEntity = - PasswordEntity( - id = id, - username = username, - score = score, - totpSecret = totpSecret, - vaultItemId = vaultItemId - ) +internal fun Password.toData(): PasswordEntity = PasswordEntity( + id = id, + username = username, + score = score, + password = password, + totpSecret = totpSecret, +) internal fun VaultPassword.toDomain(): Password = Password( id = passwordEntity.id, username = passwordEntity.username, score = passwordEntity.score, totpSecret = passwordEntity.totpSecret, + password = passwordEntity.password, passkeyRPs = rpEntity.map { it.rp }.toSet(), domainInfos = domains.map(DomainInfoEntity::toDomain).toSet(), - vaultItemId = vaultItemEntity.id, - name = vaultItemEntity.name, - note = vaultItemEntity.note, - encryptedData = vaultItemEntity.encryptedData, - pinned = vaultItemEntity.pinned, + vaultId = itemEntity.vaultId, + name = itemEntity.name, + note = itemEntity.note, + keyInformation = itemEntity.keyInformation.toDomain(), + pinned = itemEntity.pinned, ) internal fun LightweightPassword.toDomain(): LitePassword = LitePassword( - vaultItemId = vaultItemId, - passwordId = passwordId, + id = id, name = name, pinned = pinned, username = username, - domains = domains.map(DomainInfoEntity::toDomain) -) \ No newline at end of file + domains = domains.map(DomainInfoEntity::toDomain), +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultContextMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultContextMapper.kt new file mode 100644 index 00000000..a48be2e7 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultContextMapper.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.VaultContext + +internal fun VaultId?.toDomain(): VaultContext = this?.let { + VaultContext.ById(it) +} ?: VaultContext.NoSpecific \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultItemMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultItemMapper.kt deleted file mode 100644 index d0e33619..00000000 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultItemMapper.kt +++ /dev/null @@ -1,33 +0,0 @@ -package de.davis.keygo.core.item.data.maper - -import de.davis.keygo.core.item.data.local.entity.VaultItemEntity -import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItem -import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItemSearchResult -import de.davis.keygo.core.item.domain.model.VaultItem -import de.davis.keygo.core.item.domain.model.lite.LiteItem -import de.davis.keygo.core.item.domain.model.lite.LiteVaultItemSearchResult - -internal fun VaultItem.toData() = VaultItemEntity( - id = vaultItemId, - name = name, - note = note, - encryptedData = encryptedData, - itemType = itemType, - pinned = pinned, -) - -internal fun LightweightVaultItem.toDomain() = LiteItem.Concrete( - vaultItemId = id, - name = name, - itemType = itemType, - pinned = pinned -) - -internal fun LightweightVaultItemSearchResult.toDomain() = LiteVaultItemSearchResult( - vaultItemId = id, - name = name, - itemType = itemType, - matchedName = matchedName, - matchedNote = matchedNote, - pinned = pinned, -) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultMapper.kt new file mode 100644 index 00000000..0e2d4eaa --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultMapper.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.data.local.entity.VaultEntity +import de.davis.keygo.core.item.domain.model.Vault +import kotlin.time.Instant + +internal fun Vault.toData() = VaultEntity( + id = id, + name = name, + icon = icon, + keyInformation = keyInformation.toEntity(), + createdAt = createdAt.toEpochMilliseconds(), +) + +internal fun VaultEntity.toDomain() = Vault( + id = id, + name = name, + icon = icon, + keyInformation = keyInformation.toDomain(), + createdAt = Instant.fromEpochMilliseconds(createdAt), +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultMetadataMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultMetadataMapper.kt new file mode 100644 index 00000000..3492a842 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultMetadataMapper.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.data.local.pojo.VaultMetadata +import kotlin.time.Instant +import de.davis.keygo.core.item.domain.model.VaultMetadata as DomainVaultMetadata + +internal fun VaultMetadata.toDomain(): DomainVaultMetadata = DomainVaultMetadata( + vaultId = vaultId, + name = name, + icon = icon, + createdAt = Instant.fromEpochMilliseconds(createdAt), + count = count, +) + +internal fun DomainVaultMetadata.toEntity(): VaultMetadata = VaultMetadata( + vaultId = vaultId, + name = name, + icon = icon, + createdAt = createdAt.toEpochMilliseconds(), + count = count, +) \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultUpdaterMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultUpdaterMapper.kt new file mode 100644 index 00000000..6b8176cf --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/maper/VaultUpdaterMapper.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.domain.model.VaultUpdater + +internal fun VaultUpdater.toData() = de.davis.keygo.core.item.data.local.pojo.VaultUpdater( + id = id, + name = name, + icon = icon, +) \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt new file mode 100644 index 00000000..abdfb47c --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/ItemRepositoryImpl.kt @@ -0,0 +1,72 @@ +package de.davis.keygo.core.item.data.repository + +import de.davis.keygo.core.item.data.local.dao.ItemDao +import de.davis.keygo.core.item.data.local.pojo.LightweightItem +import de.davis.keygo.core.item.data.local.pojo.LightweightItemSearchResult +import de.davis.keygo.core.item.data.local.pojo.MovableItemPojo +import de.davis.keygo.core.item.data.maper.toData +import de.davis.keygo.core.item.data.maper.toDomain +import de.davis.keygo.core.item.data.maper.toEntity +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.item.domain.model.MovableItem +import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class ItemRepositoryImpl( + private val itemDao: ItemDao, +) : ItemRepository { + + override suspend fun deleteItem(itemId: ItemId) = itemDao.delete(itemId) + + override suspend fun createOrUpdateVaultItem(item: Item): ItemId { + itemDao.upsert(item.toData()) + return item.id + } + + override suspend fun getItemName(itemId: ItemId): String? = itemDao.getNameById(itemId) + + override suspend fun doesNameExist( + name: String, + excludeId: ItemId?, + vaultId: VaultId?, + ): Boolean = itemDao.existsName(name, excludeId, vaultId) + + override suspend fun searchVaultItem( + query: String, + itemType: VaultItemType?, + ): List = itemDao.searchItem(query, itemType) + .map(LightweightItemSearchResult::toDomain) + + override suspend fun setPinned(itemId: ItemId, pinned: Boolean) = + itemDao.setPinned(itemId, pinned) + + override fun observeLiteVaultItems(vaultId: VaultId?): Flow> = + itemDao.observeLiteItems(vaultId).map { it.map(LightweightItem::toDomain) } + + override suspend fun getMovableItemsByVault(vaultId: VaultId): List = + itemDao.getMovableItemsByVault(vaultId).map { it.toDomain() } + + override suspend fun moveItemsToVault( + items: List, + newVaultId: VaultId, + ): Result = runCatching { + itemDao.moveItemsToVault( + items = items.map { + MovableItemPojo(id = it.id, keyInformation = it.keyInformation.toEntity()) + }, + newVaultId = newVaultId, + ) + }.fold( + onSuccess = { Result.Success(Unit) }, + onFailure = { Result.Failure(it) }, + ) +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasswordRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasswordRepositoryImpl.kt index d71f7242..917afcb7 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasswordRepositoryImpl.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/PasswordRepositoryImpl.kt @@ -2,8 +2,8 @@ package de.davis.keygo.core.item.data.repository import androidx.room.withTransaction import de.davis.keygo.core.item.data.local.dao.DomainInfoDao +import de.davis.keygo.core.item.data.local.dao.ItemDao import de.davis.keygo.core.item.data.local.dao.PasswordDao -import de.davis.keygo.core.item.data.local.dao.VaultDao import de.davis.keygo.core.item.data.local.datasource.ItemDatabase import de.davis.keygo.core.item.data.local.pojo.LightweightPassword import de.davis.keygo.core.item.data.local.pojo.VaultPassword @@ -11,9 +11,10 @@ import de.davis.keygo.core.item.data.maper.toData import de.davis.keygo.core.item.data.maper.toDataDomainInfos import de.davis.keygo.core.item.data.maper.toDomain import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.Item import de.davis.keygo.core.item.domain.model.Password -import de.davis.keygo.core.item.domain.model.VaultItem import de.davis.keygo.core.item.domain.model.lite.LitePassword import de.davis.keygo.core.item.domain.repository.PasswordRepository import de.davis.keygo.core.util.Result @@ -24,78 +25,66 @@ import org.koin.core.annotation.Single @Single internal class PasswordRepositoryImpl( private val database: ItemDatabase, - private val vaultDao: VaultDao, + private val itemDao: ItemDao, private val passwordDao: PasswordDao, - private val domainInfoDao: DomainInfoDao + private val domainInfoDao: DomainInfoDao, ) : PasswordRepository { override suspend fun createOrUpdatePassword(password: Password): Result = runCatching { database.withTransaction { - val vaultItemId = vaultDao.upsert((password as VaultItem).toData()) - .takeIf { it != -1L } - ?: password.vaultItemId // room returned -1 meaning the item was updated + itemDao.upsert((password as Item).toData()) + passwordDao.upsert(password.toData()) + domainInfoDao.syncForPassword(password.id, password.toDataDomainInfos(password.id)) - val passwordId = - passwordDao.upsert(password.toData(vaultItemId)) - .takeIf { it != -1L } - ?: password.id - - domainInfoDao.syncForPassword(passwordId, password.toDataDomainInfos(passwordId)) - - passwordId + password.id } }.fold( onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it) } + onFailure = { Result.Failure(it) }, ) - override suspend fun updatePasswordWithDomainInfo( - vaultItemId: ItemId, + override suspend fun updateDomainInfos( + itemId: ItemId, domainInfos: Set - ): Result = runCatching { - database.withTransaction { - val password = passwordDao.getVaultPassword(vaultItemId) - ?: throw IllegalArgumentException("No password found with vault id $vaultItemId") - - val dataDomains = domainInfos.map { it.toData(password.passwordEntity.id) }.toSet() - domainInfoDao.upsertAll(dataDomains) - } - }.fold( - onSuccess = { Result.Success(Unit) }, - onFailure = { Result.Failure(it) } - ) + ): Result = + runCatching { + database.withTransaction { + val dataDomains = domainInfos.map { it.toData(itemId) }.toSet() + domainInfoDao.upsertAll(dataDomains) + } + }.fold( + onSuccess = { Result.Success(Unit) }, + onFailure = { Result.Failure(it) }, + ) override suspend fun getVaultPasswordsByTLD( etld1: String, requireTotp: Boolean, - limit: Int - ): List = - getVaultPasswordsByTLDs(setOf(etld1), requireTotp, limit) + limit: Int, + ): List = getVaultPasswordsByTLDs(setOf(etld1), requireTotp, limit) override suspend fun getVaultPasswordsByTLDs( etld1s: Set, requireTotp: Boolean, - limit: Int + limit: Int, ): List = passwordDao.getByTLDs(etld1s, requireTotp, limit).map(LightweightPassword::toDomain) - override suspend fun getPasswordById(vaultId: ItemId): Password? = - passwordDao.getVaultPassword(vaultId)?.toDomain() + override suspend fun getPasswordById(itemId: ItemId): Password? = + passwordDao.getVaultPassword(itemId)?.toDomain() + + override suspend fun getPasswordsByVault(vaultId: VaultId): List = + passwordDao.getPasswordsByVault(vaultId).map(VaultPassword::toDomain) - override fun observePasswordById(vaultId: ItemId): Flow = - passwordDao.observeVaultPassword(vaultId) - .map { it?.toDomain() } + override fun observePasswordById(itemId: ItemId): Flow = + passwordDao.observeVaultPassword(itemId).map { it?.toDomain() } - override fun observePasswords(): Flow> = passwordDao.getAllPasswords().map { - it.map(VaultPassword::toDomain) - } + override fun observePasswords(): Flow> = + passwordDao.getAllPasswords().map { it.map(VaultPassword::toDomain) } override fun observePasswordScores(): Flow> = passwordDao.observePasswordScores().map { entries -> - entries.associate { it.vaultItemId to it.score } + entries.associate { it.id to it.score } } - - override suspend fun getPasswordIdByVaultId(vaultId: ItemId): ItemId? = - passwordDao.getPasswordIdByVaultId(vaultId) -} \ No newline at end of file +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultContextRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultContextRepositoryImpl.kt new file mode 100644 index 00000000..9ceccc89 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultContextRepositoryImpl.kt @@ -0,0 +1,86 @@ +package de.davis.keygo.core.item.data.repository + +import androidx.datastore.core.DataStore +import de.davis.keygo.core.item.data.local.model.ProtoVaultContextRecord +import de.davis.keygo.core.item.data.local.model.copy +import de.davis.keygo.core.item.data.local.model.protoVaultContextRecord +import de.davis.keygo.core.item.data.maper.toDomain +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.domain.model.VaultContextRecord +import de.davis.keygo.core.item.domain.model.getIdOrNull +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class VaultContextRepositoryImpl( + private val contextDataStore: DataStore, +) : VaultContextRepository { + + override suspend fun setVaultContext(context: VaultContext) { + contextDataStore.updateData { + it.copy { + context.getIdOrNull()?.let { id -> vaultIdContext = id.toString() } + ?: clearVaultIdContext() + } + } + } + + override suspend fun setLastInteractedVault(vaultId: VaultId) { + contextDataStore.updateData { + it.copy { + lastInteractedVaultId = vaultId.toString() + } + } + } + + override suspend fun clearLastInteractedVault() { + contextDataStore.updateData { + it.copy { + clearLastInteractedVaultId() + } + } + } + + override suspend fun setContextAndLastInteracted(vaultId: VaultId) { + contextDataStore.updateData { + protoVaultContextRecord { + vaultIdContext = vaultId.toString() + lastInteractedVaultId = vaultId.toString() + } + } + } + + override fun observeVaultContext(): Flow = + contextDataStore.data.map { + val id = if (it.hasVaultIdContext()) VaultId.fromString(it.vaultIdContext) + else null + + id.toDomain() + } + + override suspend fun getLastInteractedVaultId(): VaultId? = + contextDataStore.data + .firstOrNull() + ?.takeIf { it.hasLastInteractedVaultId() } + ?.let { VaultId.fromString(it.lastInteractedVaultId) } + + override suspend fun getVaultContextRecord(): VaultContextRecord = contextDataStore.data + .first() + .let { + val contextId = if (it.hasVaultIdContext()) VaultId.fromString(it.vaultIdContext) + else null + val lastInteracted = if (it.hasLastInteractedVaultId()) + VaultId.fromString(it.lastInteractedVaultId) + else null + + VaultContextRecord( + context = contextId.toDomain(), + lastInteractedVaultId = lastInteracted, + ) + } +} \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultItemRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultItemRepositoryImpl.kt deleted file mode 100644 index 15ff1226..00000000 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultItemRepositoryImpl.kt +++ /dev/null @@ -1,48 +0,0 @@ -package de.davis.keygo.core.item.data.repository - -import de.davis.keygo.core.item.data.local.dao.VaultDao -import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItem -import de.davis.keygo.core.item.data.local.pojo.LightweightVaultItemSearchResult -import de.davis.keygo.core.item.data.maper.toData -import de.davis.keygo.core.item.data.maper.toDomain -import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.model.VaultItem -import de.davis.keygo.core.item.domain.model.lite.LiteItem -import de.davis.keygo.core.item.domain.model.lite.LiteVaultItemSearchResult -import de.davis.keygo.core.item.domain.repository.VaultItemRepository -import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map -import org.koin.core.annotation.Single - -@Single -internal class VaultItemRepositoryImpl( - private val vaultDao: VaultDao -) : VaultItemRepository { - - override suspend fun deleteItem(itemId: ItemId) = vaultDao.delete(itemId) - - override suspend fun createOrUpdateVaultItem(item: VaultItem): ItemId = - vaultDao.upsert(item.toData()) - - override suspend fun getItemName(itemId: ItemId): String? = vaultDao.getNameById(itemId) - - override suspend fun doesNameExist( - name: String, - excludeId: ItemId? - ): Boolean = vaultDao.existsName(name, excludeId) - - override suspend fun searchVaultItem( - query: String, - itemType: VaultItemType? - ): List = vaultDao.searchVaultItem(query, itemType) - .map(LightweightVaultItemSearchResult::toDomain) - - override suspend fun setPinned(itemId: ItemId, pinned: Boolean) = - vaultDao.setPinned(itemId, pinned) - - override fun observeLiteVaultItems(): Flow> = - vaultDao.observeLiteVaultItems().map { - it.map(LightweightVaultItem::toDomain) - } -} \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultRepositoryImpl.kt new file mode 100644 index 00000000..adfed7e3 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/VaultRepositoryImpl.kt @@ -0,0 +1,41 @@ +package de.davis.keygo.core.item.data.repository + +import de.davis.keygo.core.item.data.local.dao.VaultDao +import de.davis.keygo.core.item.data.maper.toData +import de.davis.keygo.core.item.data.maper.toDomain +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.core.item.domain.model.VaultUpdater +import de.davis.keygo.core.item.domain.repository.VaultRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +internal class VaultRepositoryImpl( + private val vaultDao: VaultDao, +) : VaultRepository { + override suspend fun createVault(vault: Vault) { + vaultDao.insert(vault.toData()) + } + + override suspend fun updateVault(vault: VaultUpdater) { + vaultDao.update(vault.toData()) + } + + override suspend fun deleteVault(vaultId: VaultId) = vaultDao.delete(vaultId) + + override fun observeAllVaultMetadata(): Flow> = + vaultDao.observeAllVaultMetadata().map { list -> list.map { it.toDomain() } } + + override suspend fun getVaultMetadata(vaultId: VaultId): VaultMetadata? = + vaultDao.getVaultMetadata(vaultId)?.toDomain() + + override suspend fun getKeyInformation(vaultId: VaultId): KeyInformation? = + vaultDao.getKeyInfoById(vaultId)?.toDomain() + + override suspend fun getLastCreatedVaultId(exclude: VaultId): VaultId? = + vaultDao.lastCreatedVaultId(exclude) +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/di/CoreItemModule.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/di/CoreItemModule.kt index 504e0c99..a7a7f7bb 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/di/CoreItemModule.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/di/CoreItemModule.kt @@ -1,5 +1,8 @@ package de.davis.keygo.core.item.di +import android.content.Context +import androidx.datastore.dataStore +import de.davis.keygo.core.item.data.local.VaultContextSerializer import de.davis.keygo.core.item.data.local.datasource.DatabaseModule import me.gosimple.nbvcxz.Nbvcxz import org.koin.core.annotation.ComponentScan @@ -12,6 +15,17 @@ import org.koin.core.annotation.Single @ComponentScan("de.davis.keygo.core.item") object CoreItemModule { + private const val VAULT_CONTEXT_STORE_NAME = "vault_context.pb" + + private val Context.vaultContextDataStore by dataStore( + fileName = VAULT_CONTEXT_STORE_NAME, + serializer = VaultContextSerializer, + ) + + @Single + internal fun provideVaultContextDataStore(context: Context) = + context.vaultContextDataStore + @Single internal fun provideNbvcxz() = Nbvcxz() -} \ No newline at end of file +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/alias/ItemId.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/alias/ItemId.kt index 7dae2636..5239eccf 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/alias/ItemId.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/alias/ItemId.kt @@ -1,5 +1,7 @@ package de.davis.keygo.core.item.domain.alias -typealias ItemId = Long +import java.util.UUID -const val ItemIdNone: ItemId = -1L \ No newline at end of file +typealias ItemId = UUID + +fun newItemId(): ItemId = UUID.randomUUID() diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/alias/VaultId.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/alias/VaultId.kt new file mode 100644 index 00000000..ad07eac5 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/alias/VaultId.kt @@ -0,0 +1,7 @@ +package de.davis.keygo.core.item.domain.alias + +import java.util.UUID + +typealias VaultId = UUID + +fun newVaultId(): VaultId = UUID.randomUUID() diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/crypto/SecretDataExt.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/crypto/SecretDataExt.kt deleted file mode 100644 index 7e3c71e8..00000000 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/crypto/SecretDataExt.kt +++ /dev/null @@ -1,29 +0,0 @@ -package de.davis.keygo.core.item.domain.crypto - -import de.davis.keygo.core.item.domain.model.SecretData -import de.davis.keygo.core.security.domain.crypto.CryptographicScope -import de.davis.keygo.core.security.domain.crypto.model.CryptographicData -import kotlinx.coroutines.Dispatchers -import kotlin.coroutines.CoroutineContext - -context(scope: CryptographicScope) -suspend fun SecretData.decryptSecretData(ctx: CoroutineContext = Dispatchers.Default): T = - with(scope) { - decryptedDataType.decode( - CryptographicData(data, iv).decrypt(ctx) - ) - } - -context(scope: CryptographicScope) -suspend inline fun T.encryptSecretData(ctx: CoroutineContext = Dispatchers.Default): SecretData = - with(scope) { - val decryptedDataType = SecretData.DecryptedDataType.getDecryptedDataType() - val encoded = decryptedDataType.encode(this@encryptSecretData) - - val encryptedData = encoded.encrypt(ctx) - SecretData( - data = encryptedData.data, - iv = encryptedData.iv, - decryptedDataType = decryptedDataType - ) - } \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/DomainInfo.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/DomainInfo.kt index a0f2c59c..2ff3342e 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/DomainInfo.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/DomainInfo.kt @@ -4,7 +4,7 @@ import de.davis.keygo.core.item.domain.alias.ItemId // TODO: extract additional schemes (like https) from "value" and store them separately data class DomainInfo( - val passwordId: ItemId = 0, + val passwordId: ItemId? = null, val value: String, val eTLD1: String?, ) \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt new file mode 100644 index 00000000..c8f30065 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Item.kt @@ -0,0 +1,15 @@ +package de.davis.keygo.core.item.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.processor.annotation.RootVaultEntity + +@RootVaultEntity(name = "VaultItem") +sealed interface Item : LiteItem { + override val id: ItemId + val vaultId: VaultId + override val name: String + val keyInformation: KeyInformation + val note: String? +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/KeyInformation.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/KeyInformation.kt new file mode 100644 index 00000000..139bc1fd --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/KeyInformation.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.core.item.domain.model + +class KeyInformation( + val wrappedKey: ByteArray, + val keyNonce: ByteArray, +) \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/MovableItem.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/MovableItem.kt new file mode 100644 index 00000000..b6fc1f3f --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/MovableItem.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.core.item.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId + +data class MovableItem( + val id: ItemId, + val keyInformation: KeyInformation, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Password.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Password.kt index aad0301c..582dac11 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Password.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Password.kt @@ -2,27 +2,35 @@ package de.davis.keygo.core.item.domain.model import androidx.annotation.IntRange import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.processor.annotation.VaultEntity @VaultEntity(resString = "password", defaultIconType = "Password") data class Password( - val id: ItemId = 0, + override val id: ItemId = newItemId(), val username: String?, val domainInfos: Set, val score: Score, + val password: SecretData, val totpSecret: SecretData?, val passkeyRPs: Set = emptySet(), - override val vaultItemId: ItemId = 0, + override val vaultId: VaultId, override val name: String, - override val encryptedData: SecretData, + override val keyInformation: KeyInformation, override val note: String?, override val pinned: Boolean, -) : VaultItem { +) : Item { override val itemType: VaultItemType get() = VaultItemType.Password + companion object { + const val LABEL_PASSWORD = "password" + const val LABEL_TOTP_SECRET = "totp_secret" + } + enum class Score { None, Ridiculous, @@ -35,15 +43,14 @@ data class Password( get() = this == None companion object { - operator fun invoke(@IntRange(from = 1, to = 5) value: Int): Score = - when (value) { - 1 -> Ridiculous - 2 -> Weak - 3 -> Moderate - 4 -> Strong - 5 -> Excellent - else -> None - } + operator fun invoke(@IntRange(from = 1, to = 5) value: Int): Score = when (value) { + 1 -> Ridiculous + 2 -> Weak + 3 -> Moderate + 4 -> Strong + 5 -> Excellent + else -> None + } } } -} \ No newline at end of file +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Vault.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Vault.kt new file mode 100644 index 00000000..ec348cfa --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/Vault.kt @@ -0,0 +1,59 @@ +package de.davis.keygo.core.item.domain.model + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newVaultId +import kotlin.time.Clock +import kotlin.time.Instant + +data class Vault( + val id: VaultId = newVaultId(), + val name: String, + val keyInformation: KeyInformation, + val icon: Icon, + val createdAt: Instant = Clock.System.now(), +) { + constructor( + name: String, + wrappedVaultKey: ByteArray, + vaultKeyNonce: ByteArray, + icon: Icon, + id: VaultId = newVaultId(), + ) : this( + id = id, + name = name, + keyInformation = KeyInformation( + wrappedKey = wrappedVaultKey, + keyNonce = vaultKeyNonce, + ), + icon = icon + ) + + enum class Icon { + Person, + Home, + Favorite, + Work, + Business, + School, + MenuBook, + Lock, + Computer, + PhoneAndroid, + AccountBalanceWallet, + CreditCard, + ShoppingCart, + Flight, + Hotel, + DirectionsCar, + Restaurant, + LocalCafe, + FitnessCenter, + MusicNote, + SportsEsports, + Star; + + companion object { + val Default = Person + } + } +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultContext.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultContext.kt new file mode 100644 index 00000000..f938b145 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultContext.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.core.item.domain.model + +import de.davis.keygo.core.item.domain.alias.VaultId + +sealed interface VaultContext { + data object NoSpecific : VaultContext + data class ById(val vaultId: VaultId) : VaultContext +} + +fun VaultContext.getIdOrNull(): VaultId? = when (this) { + VaultContext.NoSpecific -> null + is VaultContext.ById -> vaultId +} \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultContextRecord.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultContextRecord.kt new file mode 100644 index 00000000..704c9136 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultContextRecord.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.core.item.domain.model + +import de.davis.keygo.core.item.domain.alias.VaultId + +data class VaultContextRecord( + val context: VaultContext, + val lastInteractedVaultId: VaultId?, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultItem.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultItem.kt deleted file mode 100644 index dce5a21f..00000000 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultItem.kt +++ /dev/null @@ -1,12 +0,0 @@ -package de.davis.keygo.core.item.domain.model - -import de.davis.keygo.core.item.domain.model.lite.LiteItem -import de.davis.keygo.processor.annotation.RootVaultEntity - -@RootVaultEntity -sealed interface VaultItem : LiteItem { - override val vaultItemId: Long - override val name: String - val encryptedData: SecretData - val note: String? -} \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultMetadata.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultMetadata.kt new file mode 100644 index 00000000..0db9c751 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultMetadata.kt @@ -0,0 +1,13 @@ +package de.davis.keygo.core.item.domain.model + +import de.davis.keygo.core.item.domain.alias.VaultId +import kotlin.time.Clock +import kotlin.time.Instant + +data class VaultMetadata( + val vaultId: VaultId, + val name: String, + val icon: Vault.Icon, + val createdAt: Instant = Clock.System.now(), + val count: Int = 0, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultUpdater.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultUpdater.kt new file mode 100644 index 00000000..a965970c --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/VaultUpdater.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.core.item.domain.model + +import de.davis.keygo.core.item.domain.alias.VaultId + +data class VaultUpdater( + val id: VaultId, + val name: String, + val icon: Vault.Icon, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItemSearchResult.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt similarity index 82% rename from core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItemSearchResult.kt rename to core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt index cdcd6350..450fa2bb 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItemSearchResult.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteItemSearchResult.kt @@ -3,8 +3,8 @@ package de.davis.keygo.core.item.domain.model.lite import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.generated.domain.model.VaultItemType -data class LiteVaultItemSearchResult( - override val vaultItemId: ItemId, +data class LiteItemSearchResult( + override val id: ItemId, override val name: String, override val itemType: VaultItemType, override val pinned: Boolean, diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LitePassword.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LitePassword.kt index e1061228..cec4d392 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LitePassword.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LitePassword.kt @@ -5,12 +5,11 @@ import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.generated.domain.model.VaultItemType data class LitePassword( - override val vaultItemId: ItemId, - val passwordId: ItemId, + override val id: ItemId, override val name: String, override val pinned: Boolean, val username: String?, val domains: List, ) : LiteVaultItem { override val itemType: VaultItemType = VaultItemType.Password -} \ No newline at end of file +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItem.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItem.kt index a00b4acb..b7e335a2 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItem.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/lite/LiteVaultItem.kt @@ -7,7 +7,7 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType * A lightweight representation of a item stored in the vault. */ interface LiteItem { - val vaultItemId: ItemId + val id: ItemId val name: String val itemType: VaultItemType @@ -15,7 +15,7 @@ interface LiteItem { @ConsistentCopyVisibility data class Concrete internal constructor( - override val vaultItemId: ItemId, + override val id: ItemId, override val name: String, override val itemType: VaultItemType, override val pinned: Boolean diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt new file mode 100644 index 00000000..f5010cc1 --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/ItemRepository.kt @@ -0,0 +1,45 @@ +package de.davis.keygo.core.item.domain.repository + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.item.domain.model.MovableItem +import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.Flow + +interface ItemRepository { + + suspend fun deleteItem(itemId: ItemId) + + suspend fun createOrUpdateVaultItem(item: Item): ItemId + + suspend fun getItemName(itemId: ItemId): String? + suspend fun doesNameExist( + name: String, + excludeId: ItemId? = null, + vaultId: VaultId? = null + ): Boolean + + suspend fun searchVaultItem( + query: String, + itemType: VaultItemType? = null + ): List + + suspend fun setPinned(itemId: ItemId, pinned: Boolean) + + fun observeLiteVaultItems(vaultId: VaultId? = null): Flow> + + suspend fun getMovableItemsByVault(vaultId: VaultId): List + + /** + * Atomically moves all [items] to [newVaultId], updating each item's `vault_id` and + * key information in a single SQLite transaction. Either every row is updated or none are. + */ + suspend fun moveItemsToVault( + items: List, + newVaultId: VaultId, + ): Result +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasswordRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasswordRepository.kt index 56eec8c7..66c6d2af 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasswordRepository.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/PasswordRepository.kt @@ -1,6 +1,7 @@ package de.davis.keygo.core.item.domain.repository import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.Password import de.davis.keygo.core.item.domain.model.lite.LitePassword @@ -10,8 +11,8 @@ import kotlinx.coroutines.flow.Flow interface PasswordRepository { suspend fun createOrUpdatePassword(password: Password): Result - suspend fun updatePasswordWithDomainInfo( - vaultItemId: ItemId, + suspend fun updateDomainInfos( + itemId: ItemId, domainInfos: Set ): Result @@ -27,12 +28,12 @@ interface PasswordRepository { limit: Int = -1 ): List - suspend fun getPasswordById(vaultId: ItemId): Password? + suspend fun getPasswordById(itemId: ItemId): Password? - fun observePasswordById(vaultId: ItemId): Flow + suspend fun getPasswordsByVault(vaultId: VaultId): List + + fun observePasswordById(itemId: ItemId): Flow fun observePasswords(): Flow> fun observePasswordScores(): Flow> - - suspend fun getPasswordIdByVaultId(vaultId: ItemId): ItemId? } \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/VaultContextRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/VaultContextRepository.kt new file mode 100644 index 00000000..973ec29f --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/VaultContextRepository.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.core.item.domain.repository + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.domain.model.VaultContextRecord +import kotlinx.coroutines.flow.Flow + +interface VaultContextRepository { + + suspend fun setVaultContext(context: VaultContext) + suspend fun setLastInteractedVault(vaultId: VaultId) + suspend fun clearLastInteractedVault() + suspend fun setContextAndLastInteracted(vaultId: VaultId) + + fun observeVaultContext(): Flow + suspend fun getLastInteractedVaultId(): VaultId? + suspend fun getVaultContextRecord(): VaultContextRecord +} \ No newline at end of file diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/VaultItemRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/VaultItemRepository.kt deleted file mode 100644 index 78314b81..00000000 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/VaultItemRepository.kt +++ /dev/null @@ -1,27 +0,0 @@ -package de.davis.keygo.core.item.domain.repository - -import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.model.VaultItem -import de.davis.keygo.core.item.domain.model.lite.LiteItem -import de.davis.keygo.core.item.domain.model.lite.LiteVaultItemSearchResult -import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import kotlinx.coroutines.flow.Flow - -interface VaultItemRepository { - - suspend fun deleteItem(itemId: ItemId) - - suspend fun createOrUpdateVaultItem(item: VaultItem): ItemId - - suspend fun getItemName(itemId: ItemId): String? - suspend fun doesNameExist(name: String, excludeId: ItemId? = null): Boolean - - suspend fun searchVaultItem( - query: String, - itemType: VaultItemType? = null - ): List - - suspend fun setPinned(itemId: ItemId, pinned: Boolean) - - fun observeLiteVaultItems(): Flow> -} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/VaultRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/VaultRepository.kt new file mode 100644 index 00000000..4868245d --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/VaultRepository.kt @@ -0,0 +1,20 @@ +package de.davis.keygo.core.item.domain.repository + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.core.item.domain.model.VaultUpdater +import kotlinx.coroutines.flow.Flow + +interface VaultRepository { + suspend fun createVault(vault: Vault) + suspend fun updateVault(vault: VaultUpdater) + suspend fun deleteVault(vaultId: VaultId) + fun observeAllVaultMetadata(): Flow> + suspend fun getVaultMetadata(vaultId: VaultId): VaultMetadata? + + suspend fun getKeyInformation(vaultId: VaultId): KeyInformation? + + suspend fun getLastCreatedVaultId(exclude: VaultId): VaultId? +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCase.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCase.kt index 421577a9..6c1c1f80 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCase.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCase.kt @@ -1,8 +1,8 @@ package de.davis.keygo.core.item.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.Item import de.davis.keygo.core.item.domain.model.Password -import de.davis.keygo.core.item.domain.model.VaultItem import de.davis.keygo.core.item.domain.repository.PasswordRepository import de.davis.keygo.core.util.Result import org.koin.core.annotation.Single @@ -12,7 +12,7 @@ class UpsertVaultItemUseCase( private val passwordRepository: PasswordRepository, ) { - suspend operator fun invoke(item: VaultItem): Result = when (item) { + suspend operator fun invoke(item: Item): Result = when (item) { is Password -> { passwordRepository.createOrUpdatePassword(item) } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/presentation/VaultIconMapper.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/presentation/VaultIconMapper.kt new file mode 100644 index 00000000..05a03d6b --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/presentation/VaultIconMapper.kt @@ -0,0 +1,51 @@ +package de.davis.keygo.core.item.presentation + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.MenuBook +import androidx.compose.material.icons.filled.AccountBalanceWallet +import androidx.compose.material.icons.filled.Business +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FitnessCenter +import androidx.compose.material.icons.filled.Flight +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Hotel +import androidx.compose.material.icons.filled.LocalCafe +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.MusicNote +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Restaurant +import androidx.compose.material.icons.filled.School +import androidx.compose.material.icons.filled.ShoppingCart +import androidx.compose.material.icons.filled.SportsEsports +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.Work +import de.davis.keygo.core.item.domain.model.Vault + +fun Vault.Icon.toImageVector() = when (this) { + Vault.Icon.Person -> Icons.Default.Person + Vault.Icon.Home -> Icons.Default.Home + Vault.Icon.Favorite -> Icons.Default.Favorite + Vault.Icon.Work -> Icons.Default.Work + Vault.Icon.Business -> Icons.Default.Business + Vault.Icon.School -> Icons.Default.School + Vault.Icon.MenuBook -> Icons.AutoMirrored.Default.MenuBook + Vault.Icon.Lock -> Icons.Default.Lock + Vault.Icon.Computer -> Icons.Default.Computer + Vault.Icon.PhoneAndroid -> Icons.Default.PhoneAndroid + Vault.Icon.AccountBalanceWallet -> Icons.Default.AccountBalanceWallet + Vault.Icon.CreditCard -> Icons.Default.CreditCard + Vault.Icon.ShoppingCart -> Icons.Default.ShoppingCart + Vault.Icon.Flight -> Icons.Default.Flight + Vault.Icon.Hotel -> Icons.Default.Hotel + Vault.Icon.DirectionsCar -> Icons.Default.DirectionsCar + Vault.Icon.Restaurant -> Icons.Default.Restaurant + Vault.Icon.LocalCafe -> Icons.Default.LocalCafe + Vault.Icon.FitnessCenter -> Icons.Default.FitnessCenter + Vault.Icon.MusicNote -> Icons.Default.MusicNote + Vault.Icon.SportsEsports -> Icons.Default.SportsEsports + Vault.Icon.Star -> Icons.Default.Star +} diff --git a/core/item/src/main/proto/vault_context_record.proto b/core/item/src/main/proto/vault_context_record.proto new file mode 100644 index 00000000..4bc72338 --- /dev/null +++ b/core/item/src/main/proto/vault_context_record.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +package de.davis.keygo.core.item.data.local.model; +option java_multiple_files = true; + +message ProtoVaultContextRecord { + optional string vault_id_context = 1; + optional string last_interacted_vault_id = 2; +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/DomainMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/DomainMapperTest.kt new file mode 100644 index 00000000..4927d569 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/DomainMapperTest.kt @@ -0,0 +1,109 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.model.SecretData +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DomainMapperTest { + + private fun password( + id: ItemId = newItemId(), + domainInfos: Set = emptySet(), + ) = Password( + id = id, + username = null, + domainInfos = domainInfos, + score = Password.Score.Strong, + password = SecretData.EMPTY_STRING, + totpSecret = null, + vaultId = newVaultId(), + name = "Test", + keyInformation = KeyInformation(wrappedKey = byteArrayOf(), keyNonce = byteArrayOf()), + note = null, + pinned = false, + ) + + // toData + + @Test + fun `toData maps value and eTLD1`() { + val id = newItemId() + val info = DomainInfo(value = "https://example.com", eTLD1 = "example.com") + + val entity = info.toData(id) + + assertEquals("https://example.com", entity.value) + assertEquals("example.com", entity.eTLD1) + } + + @Test + fun `toData assigns provided passwordId`() { + val id = newItemId() + val entity = DomainInfo(value = "https://example.com", eTLD1 = null).toData(id) + + assertEquals(id, entity.passwordId) + } + + @Test + fun `toData preserves null eTLD1`() { + val entity = DomainInfo(value = "https://example.com", eTLD1 = null).toData(newItemId()) + + assertNull(entity.eTLD1) + } + + // toDomain + + @Test + fun `toDomain round-trips all fields`() { + val id = newItemId() + val info = DomainInfo(passwordId = id, value = "https://example.com", eTLD1 = "example.com") + + val entity = info.toData(id) + val result = entity.toDomain() + + assertEquals(info, result) + } + + // toDataDomainInfos + + @Test + fun `toDataDomainInfos maps every DomainInfo to an entity`() { + val id = newItemId() + val infos = setOf( + DomainInfo(value = "https://example.com", eTLD1 = "example.com"), + DomainInfo(value = "https://other.com", eTLD1 = "other.com"), + ) + + val entities = password(id = id, domainInfos = infos).toDataDomainInfos(id) + + assertEquals(2, entities.size) + } + + @Test + fun `toDataDomainInfos assigns the given passwordId to all entities`() { + val id = newItemId() + val infos = setOf( + DomainInfo(value = "https://a.com", eTLD1 = "a.com"), + DomainInfo(value = "https://b.com", eTLD1 = "b.com"), + ) + + val entities = password(id = id, domainInfos = infos).toDataDomainInfos(id) + + assertTrue(entities.all { it.passwordId == id }) + } + + @Test + fun `toDataDomainInfos on empty domainInfos returns empty set`() { + val entities = password(domainInfos = emptySet()).toDataDomainInfos(newItemId()) + + assertTrue(entities.isEmpty()) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/ItemMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/ItemMapperTest.kt new file mode 100644 index 00000000..ad2a2f31 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/ItemMapperTest.kt @@ -0,0 +1,175 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.data.local.pojo.LightweightItem +import de.davis.keygo.core.item.data.local.pojo.LightweightItemSearchResult +import de.davis.keygo.core.item.data.local.pojo.MovableItemPojo +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.model.SecretData +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation + +class ItemMapperTest { + + private fun testPassword( + name: String = "My password", + note: String? = null, + pinned: Boolean = false, + ) = Password( + id = newItemId(), + name = name, + username = null, + domainInfos = emptySet(), + score = Password.Score.Strong, + password = SecretData.EMPTY_STRING, + totpSecret = null, + note = note, + pinned = pinned, + vaultId = newVaultId(), + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ) + + // Item.toData() → ItemEntity + + @Test + fun `Password toData maps id and vaultId`() { + val password = testPassword() + val entity = (password as Item).toData() + + assertEquals(password.id, entity.id) + assertEquals(password.vaultId, entity.vaultId) + } + + @Test + fun `Password toData maps name`() { + val entity = (testPassword(name = "Work login") as Item).toData() + assertEquals("Work login", entity.name) + } + + @Test + fun `Password toData maps null note`() { + assertEquals(null, (testPassword(note = null) as Item).toData().note) + } + + @Test + fun `Password toData maps non-null note`() { + assertEquals("My note", (testPassword(note = "My note") as Item).toData().note) + } + + @Test + fun `Password toData sets itemType to Password`() { + assertEquals(VaultItemType.Password, (testPassword() as Item).toData().itemType) + } + + @Test + fun `Password toData maps pinned`() { + assertTrue((testPassword(pinned = true) as Item).toData().pinned) + assertFalse((testPassword(pinned = false) as Item).toData().pinned) + } + + // LightweightItem.toDomain() → LiteItem.Concrete + + @Test + fun `LightweightItem toDomain copies all fields`() { + val id = newItemId() + val lightweight = LightweightItem( + id = id, + name = "Test item", + itemType = VaultItemType.Password, + pinned = true, + ) + + val lite = lightweight.toDomain() + + assertEquals(id, lite.id) + assertEquals("Test item", lite.name) + assertEquals(VaultItemType.Password, lite.itemType) + assertTrue(lite.pinned) + } + + // LightweightItemSearchResult.toDomain() → LiteItemSearchResult + + @Test + fun `LightweightItemSearchResult toDomain copies all fields`() { + val id = newItemId() + val pojo = LightweightItemSearchResult( + id = id, + name = "Search result", + itemType = VaultItemType.Password, + matchedName = true, + matchedNote = false, + pinned = false, + ) + + val result = pojo.toDomain() + + assertEquals(id, result.id) + assertEquals("Search result", result.name) + assertEquals(VaultItemType.Password, result.itemType) + assertTrue(result.matchedName) + assertFalse(result.matchedNote) + assertFalse(result.pinned) + } + + // MovableItemPojo.toDomain() → MovableItem + + @Test + fun `MovableItemPojo toDomain maps id`() { + val id = newItemId() + val pojo = MovableItemPojo( + id = id, + keyInformation = EntityKeyInformation(byteArrayOf(), byteArrayOf()), + ) + + assertEquals(id, pojo.toDomain().id) + } + + @Test + fun `MovableItemPojo toDomain copies wrappedKey bytes`() { + val pojo = MovableItemPojo( + id = newItemId(), + keyInformation = EntityKeyInformation( + wrappedKey = byteArrayOf(10, 20, 30), + keyNonce = byteArrayOf(), + ), + ) + + assertContentEquals(byteArrayOf(10, 20, 30), pojo.toDomain().keyInformation.wrappedKey) + } + + @Test + fun `MovableItemPojo toDomain copies keyNonce bytes`() { + val pojo = MovableItemPojo( + id = newItemId(), + keyInformation = EntityKeyInformation( + wrappedKey = byteArrayOf(), + keyNonce = byteArrayOf(7, 8, 9), + ), + ) + + assertContentEquals(byteArrayOf(7, 8, 9), pojo.toDomain().keyInformation.keyNonce) + } + + @Test + fun `MovableItemPojo toDomain maps both key fields independently`() { + val pojo = MovableItemPojo( + id = newItemId(), + keyInformation = EntityKeyInformation( + wrappedKey = byteArrayOf(0xAA.toByte(), 0xBB.toByte()), + keyNonce = byteArrayOf(0xCC.toByte(), 0xDD.toByte()), + ), + ) + + val domain = pojo.toDomain() + assertContentEquals(byteArrayOf(0xAA.toByte(), 0xBB.toByte()), domain.keyInformation.wrappedKey) + assertContentEquals(byteArrayOf(0xCC.toByte(), 0xDD.toByte()), domain.keyInformation.keyNonce) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/KeyInformationMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/KeyInformationMapperTest.kt new file mode 100644 index 00000000..8a6f6a2b --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/KeyInformationMapperTest.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.core.item.data.maper + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation +import de.davis.keygo.core.item.domain.model.KeyInformation as DomainKeyInformation + +class KeyInformationMapperTest { + + // toEntity + + @Test + fun `toEntity copies wrappedKey bytes`() { + val domain = + DomainKeyInformation(wrappedKey = byteArrayOf(1, 2, 3), keyNonce = byteArrayOf()) + + assertContentEquals(byteArrayOf(1, 2, 3), domain.toEntity().wrappedKey) + } + + @Test + fun `toEntity copies keyNonce bytes`() { + val domain = + DomainKeyInformation(wrappedKey = byteArrayOf(), keyNonce = byteArrayOf(4, 5, 6)) + + assertContentEquals(byteArrayOf(4, 5, 6), domain.toEntity().keyNonce) + } + + // toDomain + + @Test + fun `toDomain copies wrappedKey bytes`() { + val entity = EntityKeyInformation(wrappedKey = byteArrayOf(7, 8), keyNonce = byteArrayOf()) + + assertContentEquals(byteArrayOf(7, 8), entity.toDomain().wrappedKey) + } + + @Test + fun `toDomain copies keyNonce bytes`() { + val entity = EntityKeyInformation(wrappedKey = byteArrayOf(), keyNonce = byteArrayOf(9, 10)) + + assertContentEquals(byteArrayOf(9, 10), entity.toDomain().keyNonce) + } + + // round-trip + + @Test + fun `domain round-trip preserves wrappedKey content`() { + val original = + DomainKeyInformation(wrappedKey = byteArrayOf(1, 2, 3), keyNonce = byteArrayOf(4)) + + val roundTripped = original.toEntity().toDomain() + + assertContentEquals(original.wrappedKey, roundTripped.wrappedKey) + assertContentEquals(original.keyNonce, roundTripped.keyNonce) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/PasskeyMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/PasskeyMapperTest.kt new file mode 100644 index 00000000..65bcb81a --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/PasskeyMapperTest.kt @@ -0,0 +1,145 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.data.local.entity.PasskeyEntity +import de.davis.keygo.core.item.data.local.pojo.PasskeyMetadataPojo +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.Passkey +import de.davis.keygo.core.item.domain.model.PasskeyUser +import de.davis.keygo.core.item.domain.model.SecretData +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PasskeyMapperTest { + + private val credentialId = byteArrayOf(1, 2, 3, 4) + private val privateKey = SecretData.EMPTY_STRING + private val user = PasskeyUser(name = "alice", displayName = "Alice Smith") + + private fun testPasskey() = Passkey( + credentialId = credentialId, + rp = "example.com", + privateKey = privateKey, + passwordId = newItemId(), + user = user, + ) + + // Passkey.toData() + + @Test + fun `toData copies credentialId`() { + val entity = testPasskey().toData() + assertContentEquals(credentialId, entity.credentialId) + } + + @Test + fun `toData copies rp`() { + assertEquals("example.com", testPasskey().toData().rp) + } + + @Test + fun `toData copies privateKey`() { + assertEquals(privateKey, testPasskey().toData().privateKey) + } + + @Test + fun `toData copies passwordId`() { + val passkey = testPasskey() + assertEquals(passkey.passwordId, passkey.toData().passwordId) + } + + @Test + fun `toData flattens user name and displayName`() { + val entity = testPasskey().toData() + assertEquals("alice", entity.name) + assertEquals("Alice Smith", entity.displayName) + } + + // PasskeyEntity.toDomain() + + @Test + fun `PasskeyEntity toDomain copies credentialId`() { + val entity = passkeyEntity() + assertContentEquals(credentialId, entity.toDomain().credentialId) + } + + @Test + fun `PasskeyEntity toDomain copies rp`() { + assertEquals("example.com", passkeyEntity().toDomain().rp) + } + + @Test + fun `PasskeyEntity toDomain reconstructs user`() { + val domain = passkeyEntity(name = "bob", displayName = "Bob Jones").toDomain() + assertEquals("bob", domain.user.name) + assertEquals("Bob Jones", domain.user.displayName) + } + + @Test + fun `round-trip Passkey→entity→domain preserves equality`() { + val original = testPasskey() + assertEquals(original, original.toData().toDomain()) + } + + // PasskeyMetadataPojo.toDomain() + + @Test + fun `PasskeyMetadataPojo toDomain copies vaultName`() { + val metadata = pojo(vaultName = "Personal").toDomain() + assertEquals("Personal", metadata.vaultName) + } + + @Test + fun `PasskeyMetadataPojo toDomain copies credentialId`() { + val metadata = pojo().toDomain() + assertContentEquals(credentialId, metadata.credentialId) + } + + @Test + fun `PasskeyMetadataPojo toDomain reconstructs user`() { + val metadata = pojo(name = "carol", displayName = "Carol White").toDomain() + assertEquals("carol", metadata.user.name) + assertEquals("Carol White", metadata.user.displayName) + } + + @Test + fun `PasskeyMetadataPojo toDomain preserves null pwdUsername`() { + assertNull(pojo(pwdUsername = null).toDomain().passwordUsername) + } + + @Test + fun `PasskeyMetadataPojo toDomain preserves non-null pwdUsername`() { + assertEquals( + "alice@example.com", + pojo(pwdUsername = "alice@example.com").toDomain().passwordUsername + ) + } + + // Helpers + + private fun passkeyEntity( + name: String = "alice", + displayName: String = "Alice Smith", + ) = PasskeyEntity( + credentialId = credentialId, + rp = "example.com", + privateKey = privateKey, + passwordId = newItemId(), + name = name, + displayName = displayName, + ) + + private fun pojo( + vaultName: String = "Personal", + pwdUsername: String? = "alice@example.com", + name: String = "alice", + displayName: String = "Alice Smith", + ) = PasskeyMetadataPojo( + vaultName = vaultName, + pwdUsername = pwdUsername, + name = name, + displayName = displayName, + credentialId = credentialId, + ) +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/PasswordMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/PasswordMapperTest.kt new file mode 100644 index 00000000..828dc8b8 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/PasswordMapperTest.kt @@ -0,0 +1,208 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.data.local.entity.DomainInfoEntity +import de.davis.keygo.core.item.data.local.entity.ItemEntity +import de.davis.keygo.core.item.data.local.entity.PasswordEntity +import de.davis.keygo.core.item.data.local.pojo.LightweightPassword +import de.davis.keygo.core.item.data.local.pojo.RP +import de.davis.keygo.core.item.data.local.pojo.VaultPassword +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.model.SecretData +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import de.davis.keygo.core.item.data.local.entity.KeyInformation as EntityKeyInformation + +class PasswordMapperTest { + + private fun testPassword( + username: String? = "user@example.com", + score: Password.Score = Password.Score.Strong, + totpSecret: SecretData? = null, + ) = Password( + id = newItemId(), + name = "My password", + username = username, + domainInfos = emptySet(), + score = score, + password = SecretData.EMPTY_STRING, + totpSecret = totpSecret, + note = "A note", + pinned = false, + vaultId = newVaultId(), + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ) + + private fun entityKeyInfo() = EntityKeyInformation(byteArrayOf(), byteArrayOf()) + + // Password.toData() → PasswordEntity + + @Test + fun `toData preserves id`() { + val password = testPassword() + assertEquals(password.id, password.toData().id) + } + + @Test + fun `toData preserves username`() { + val password = testPassword(username = "alice@example.com") + assertEquals("alice@example.com", password.toData().username) + } + + @Test + fun `toData preserves null username`() { + val password = testPassword(username = null) + assertNull(password.toData().username) + } + + @Test + fun `toData preserves score`() { + val password = testPassword(score = Password.Score.Excellent) + assertEquals(Password.Score.Excellent, password.toData().score) + } + + @Test + fun `toData preserves null totpSecret`() { + assertNull(testPassword(totpSecret = null).toData().totpSecret) + } + + @Test + fun `toData preserves non-null totpSecret`() { + val secret = SecretData.EMPTY_STRING + assertEquals(secret, testPassword(totpSecret = secret).toData().totpSecret) + } + + // VaultPassword.toDomain() → Password + + @Test + fun `VaultPassword toDomain copies id from passwordEntity`() { + val id = newItemId() + val vaultPassword = vaultPassword(id = id) + + assertEquals(id, vaultPassword.toDomain().id) + } + + @Test + fun `VaultPassword toDomain copies username`() { + val vaultPassword = vaultPassword(username = "bob@example.com") + assertEquals("bob@example.com", vaultPassword.toDomain().username) + } + + @Test + fun `VaultPassword toDomain copies name and note from itemEntity`() { + val vaultPassword = vaultPassword(name = "Work site", note = "Remember this") + val domain = vaultPassword.toDomain() + + assertEquals("Work site", domain.name) + assertEquals("Remember this", domain.note) + } + + @Test + fun `VaultPassword toDomain maps domains`() { + val domainEntity = DomainInfoEntity( + passwordId = newItemId(), + value = "https://example.com", + eTLD1 = "example.com", + ) + val vaultPassword = vaultPassword(domains = listOf(domainEntity)) + + assertEquals(1, vaultPassword.toDomain().domainInfos.size) + assertEquals("https://example.com", vaultPassword.toDomain().domainInfos.first().value) + } + + @Test + fun `VaultPassword toDomain maps passkeyRPs`() { + val vaultPassword = vaultPassword(rpEntities = listOf(RP("example.com"), RP("other.com"))) + val domain = vaultPassword.toDomain() + + assertEquals(setOf("example.com", "other.com"), domain.passkeyRPs) + } + + @Test + fun `VaultPassword toDomain with empty domains and rps`() { + val domain = vaultPassword().toDomain() + + assertTrue(domain.domainInfos.isEmpty()) + assertTrue(domain.passkeyRPs.isEmpty()) + } + + // LightweightPassword.toDomain() → LitePassword + + @Test + fun `LightweightPassword toDomain copies id, name, pinned, username`() { + val id = newItemId() + val lightweight = LightweightPassword( + id = id, + name = "Test", + pinned = true, + username = "carol@example.com", + domains = emptyList(), + ) + + val lite = lightweight.toDomain() + + assertEquals(id, lite.id) + assertEquals("Test", lite.name) + assertEquals(true, lite.pinned) + assertEquals("carol@example.com", lite.username) + } + + @Test + fun `LightweightPassword toDomain maps domains`() { + val lightweight = LightweightPassword( + id = newItemId(), + name = "Test", + pinned = false, + username = null, + domains = listOf( + DomainInfoEntity( + passwordId = newItemId(), + value = "https://example.com", + eTLD1 = "example.com" + ), + ), + ) + + assertEquals(1, lightweight.toDomain().domains.size) + assertEquals("https://example.com", lightweight.toDomain().domains.first().value) + } + + // Helper + + private fun vaultPassword( + id: ItemId = newItemId(), + username: String? = null, + name: String = "Test", + note: String? = null, + domains: List = emptyList(), + rpEntities: List = emptyList(), + ): VaultPassword { + val vaultId = newVaultId() + return VaultPassword( + passwordEntity = PasswordEntity( + id = id, + username = username, + score = Password.Score.Strong, + password = SecretData.EMPTY_STRING, + totpSecret = null, + ), + itemEntity = ItemEntity( + id = id, + vaultId = vaultId, + name = name, + note = note, + itemType = VaultItemType.Password, + pinned = false, + keyInformation = entityKeyInfo(), + ), + rpEntity = rpEntities, + domains = domains, + ) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/VaultContextMapperTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/VaultContextMapperTest.kt new file mode 100644 index 00000000..e824de42 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/data/maper/VaultContextMapperTest.kt @@ -0,0 +1,26 @@ +package de.davis.keygo.core.item.data.maper + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.VaultContext +import kotlin.test.Test +import kotlin.test.assertEquals + +class VaultContextMapperTest { + + @Test + fun `null id maps to NoSpecific`() { + val context: VaultContext = (null as VaultId?).toDomain() + + assertEquals(VaultContext.NoSpecific, context) + } + + @Test + fun `non-null id maps to ById preserving the id`() { + val id = newVaultId() + + val context = id.toDomain() + + assertEquals(VaultContext.ById(id), context) + } +} diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCaseTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt similarity index 54% rename from core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCaseTest.kt rename to core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt index 03b33cc0..1b573c64 100644 --- a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertVaultItemUseCaseTest.kt +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/usecase/UpsertItemUseCaseTest.kt @@ -1,67 +1,68 @@ package de.davis.keygo.core.item.domain.usecase +import de.davis.keygo.core.item.FakePasswordRepository +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Password import de.davis.keygo.core.item.domain.model.SecretData -import de.davis.keygo.core.item.domain.repository.PasswordRepository -import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.isFailure import de.davis.keygo.core.util.isSuccess -import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.mockk import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull import kotlin.test.assertTrue -class UpsertVaultItemUseCaseTest { +class UpsertItemUseCaseTest { - private val passwordRepository = mockk() + private val passwordRepository = FakePasswordRepository() private val useCase = UpsertVaultItemUseCase(passwordRepository) private fun testPassword(name: String = "Test") = Password( - id = 0, + id = newItemId(), username = "user", domainInfos = emptySet(), score = Password.Score.Strong, totpSecret = null, - vaultItemId = 0, name = name, - encryptedData = SecretData.EMPTY_STRING, + password = SecretData.EMPTY_STRING, note = null, - pinned = false + pinned = false, + vaultId = newVaultId(), + keyInformation = KeyInformation( + wrappedKey = byteArrayOf(), + keyNonce = byteArrayOf(), + ), ) @Test fun `delegates password to passwordRepository`() = runTest { val password = testPassword() - coEvery { passwordRepository.createOrUpdatePassword(password) } returns Result.Success(1L) useCase(password) - coVerify { passwordRepository.createOrUpdatePassword(password) } + assertNotNull(passwordRepository.getPasswordById(password.id)) } @Test fun `returns success with item id`() = runTest { val password = testPassword() - coEvery { passwordRepository.createOrUpdatePassword(password) } returns Result.Success(42L) val result = useCase(password) assertTrue(result.isSuccess()) - assertEquals(42L, (result as Result.Success).success) + assertEquals(password.id, result.success) } @Test fun `returns failure from repository`() = runTest { - val password = testPassword() val error = RuntimeException("db error") - coEvery { passwordRepository.createOrUpdatePassword(password) } returns Result.Failure(error) + passwordRepository.createOrUpdateError = error - val result = useCase(password) + val result = useCase(testPassword()) assertTrue(result.isFailure()) - assertEquals(error, (result as Result.Failure).error) + assertEquals(error, result.error) } } diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt new file mode 100644 index 00000000..354ee784 --- /dev/null +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeItemRepository.kt @@ -0,0 +1,72 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.item.domain.model.MovableItem +import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * In-memory [ItemRepository] for tests, layered on top of a [FakePasswordRepository] so the + * password store is the single source of truth for vault/key state. + */ +class FakeItemRepository( + private val passwordRepository: FakePasswordRepository, +) : ItemRepository { + + /** + * If non-null, [moveItemsToVault] fails the whole bulk write when any of the supplied + * items has this id. Models a SQLite transaction failing and rolling back. + * Persists across calls; the consumer clears it explicitly. + */ + var failMoveForId: Pair? = null + + override suspend fun deleteItem(itemId: ItemId) = Unit + + override suspend fun createOrUpdateVaultItem(item: Item): ItemId = item.id + + override suspend fun getItemName(itemId: ItemId): String? = + passwordRepository.getPasswordById(itemId)?.name + + override suspend fun doesNameExist( + name: String, + excludeId: ItemId?, + vaultId: VaultId?, + ): Boolean = false + + override suspend fun searchVaultItem( + query: String, + itemType: VaultItemType?, + ): List = emptyList() + + override suspend fun setPinned(itemId: ItemId, pinned: Boolean) = Unit + + override fun observeLiteVaultItems(vaultId: VaultId?): Flow> = + flowOf(emptyList()) + + override suspend fun getMovableItemsByVault(vaultId: VaultId): List = + passwordRepository.getPasswordsByVault(vaultId) + .map { MovableItem(id = it.id, keyInformation = it.keyInformation) } + + override suspend fun moveItemsToVault( + items: List, + newVaultId: VaultId, + ): Result { + failMoveForId?.let { (failId, error) -> + if (items.any { it.id == failId }) return Result.Failure(error) + } + val updates = items.map { item -> + val existing = passwordRepository.getPasswordById(item.id) + ?: return Result.Failure(NoSuchElementException("No item with id ${item.id}")) + existing.copy(vaultId = newVaultId, keyInformation = item.keyInformation) + } + updates.forEach { passwordRepository.seed(it) } + return Result.Success(Unit) + } +} diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasswordRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasswordRepository.kt new file mode 100644 index 00000000..ef51336f --- /dev/null +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasswordRepository.kt @@ -0,0 +1,87 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.model.lite.LitePassword +import de.davis.keygo.core.item.domain.repository.PasswordRepository +import de.davis.keygo.core.util.Result +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** + * In-memory [PasswordRepository] for tests. + * + * - Pre-populate via [seed]. + * - Force the next [createOrUpdatePassword] call to fail by setting [createOrUpdateError]. + * - Force [createOrUpdatePassword] to fail for a specific id by setting [failCreateOrUpdateForId]. + * - Flow-returning methods react to store mutations, so observers see live updates. + */ +class FakePasswordRepository : PasswordRepository { + + private val store = MutableStateFlow>(emptyMap()) + + /** Error returned by the next [createOrUpdatePassword] call (cleared after use). */ + var createOrUpdateError: Throwable? = null + + /** + * If non-null, [createOrUpdatePassword] fails when called with this id. + * Persists across calls; the consumer clears it explicitly. + */ + var failCreateOrUpdateForId: Pair? = null + + fun seed(vararg passwords: Password) { + store.update { it + passwords.associateBy { p -> p.id } } + } + + override suspend fun createOrUpdatePassword(password: Password): Result { + failCreateOrUpdateForId?.let { (id, error) -> + if (id == password.id) return Result.Failure(error) + } + createOrUpdateError?.let { + createOrUpdateError = null + return Result.Failure(it) + } + store.update { it + (password.id to password) } + return Result.Success(password.id) + } + + override suspend fun updateDomainInfos( + itemId: ItemId, + domainInfos: Set, + ): Result { + val existing = store.value[itemId] + ?: return Result.Failure(NoSuchElementException("No password with id $itemId")) + store.update { it + (itemId to existing.copy(domainInfos = domainInfos)) } + return Result.Success(Unit) + } + + override suspend fun getVaultPasswordsByTLD( + etld1: String, + requireTotp: Boolean, + limit: Int, + ): List = emptyList() + + override suspend fun getVaultPasswordsByTLDs( + etld1s: Set, + requireTotp: Boolean, + limit: Int, + ): List = emptyList() + + override suspend fun getPasswordById(itemId: ItemId): Password? = store.value[itemId] + + override suspend fun getPasswordsByVault(vaultId: VaultId): List = + store.value.values.filter { it.vaultId == vaultId } + + override fun observePasswordById(itemId: ItemId): Flow = + store.map { it[itemId] } + + override fun observePasswords(): Flow> = + store.map { it.values.toList() } + + override fun observePasswordScores(): Flow> = + store.map { passwords -> passwords.mapValues { it.value.score } } +} diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasswordStrengthEstimator.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasswordStrengthEstimator.kt new file mode 100644 index 00000000..ac951a4c --- /dev/null +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakePasswordStrengthEstimator.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator +import de.davis.keygo.core.item.domain.model.Password + +/** + * [PasswordStrengthEstimator] fake that always returns [Password.Score.Strong]. + * Inject a custom [score] when score-specific behaviour needs asserting. + */ +class FakePasswordStrengthEstimator( + val score: Password.Score = Password.Score.Strong, +) : PasswordStrengthEstimator { + override suspend fun estimate(password: String): Password.Score = score +} \ No newline at end of file diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeVaultContextRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeVaultContextRepository.kt new file mode 100644 index 00000000..bfc33440 --- /dev/null +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeVaultContextRepository.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.domain.model.VaultContextRecord +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * In-memory [VaultContextRepository] for tests. + * + * - [observeVaultContext] reflects mutations made via the setters. + * - [getLastInteractedVaultId] returns null when no last interacted vault has been recorded; + * pre-seed via [seedLastInteracted] when a test relies on reading it back. + */ +class FakeVaultContextRepository( + initialContext: VaultContext = VaultContext.NoSpecific, +) : VaultContextRepository { + + private val context = MutableStateFlow(initialContext) + private val lastInteracted = MutableStateFlow(null) + + val currentContext: VaultContext get() = context.value + val currentLastInteracted: VaultId? get() = lastInteracted.value + + fun seedLastInteracted(vaultId: VaultId) { + lastInteracted.value = vaultId + } + + override suspend fun setVaultContext(context: VaultContext) { + this.context.value = context + } + + override suspend fun setLastInteractedVault(vaultId: VaultId) { + lastInteracted.value = vaultId + } + + override suspend fun clearLastInteractedVault() { + lastInteracted.value = null + } + + override suspend fun setContextAndLastInteracted(vaultId: VaultId) { + context.value = VaultContext.ById(vaultId) + lastInteracted.value = vaultId + } + + override fun observeVaultContext(): Flow = context + + override suspend fun getLastInteractedVaultId(): VaultId? = lastInteracted.value + + override suspend fun getVaultContextRecord(): VaultContextRecord = VaultContextRecord( + context = currentContext, + lastInteractedVaultId = lastInteracted.value, + ) +} diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeVaultRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeVaultRepository.kt new file mode 100644 index 00000000..f367c2e2 --- /dev/null +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeVaultRepository.kt @@ -0,0 +1,73 @@ +package de.davis.keygo.core.item + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.core.item.domain.model.VaultUpdater +import de.davis.keygo.core.item.domain.repository.VaultRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update + +/** + * In-memory [VaultRepository] for tests. + * + * - Pre-populate via [seed]. + * - Force the next [createVault] call to throw by setting [createError]. + * - Flow-returning methods react to store mutations, so observers see live updates. + */ +class FakeVaultRepository : VaultRepository { + + private val store = MutableStateFlow>(emptyMap()) + + /** Exception thrown by the next [createVault] call (cleared after use). */ + var createError: Throwable? = null + + /** Initialize this repo with [vaults]. */ + fun seed(vararg vaults: Vault) { + store.update { it + vaults.associateBy { v -> v.id } } + } + + override suspend fun getKeyInformation(vaultId: VaultId): KeyInformation? = + store.value[vaultId]?.keyInformation + + override suspend fun createVault(vault: Vault) { + createError?.let { createError = null; throw it } + store.update { it + (vault.id to vault) } + } + + override suspend fun updateVault(vault: VaultUpdater) { + store.value[vault.id]?.let { existing -> + val updated = existing.copy( + name = vault.name, + icon = vault.icon, + ) + store.update { it + (vault.id to updated) } + } + } + + override suspend fun deleteVault(vaultId: VaultId) { + store.update { it - vaultId } + } + + override fun observeAllVaultMetadata(): Flow> = + store.map { vaults -> vaults.values.map { it.toMetadata() } } + + override suspend fun getVaultMetadata(vaultId: VaultId): VaultMetadata? = + store.value[vaultId]?.toMetadata() + + override suspend fun getLastCreatedVaultId(exclude: VaultId): VaultId? = + store.value.keys.lastOrNull { it != exclude } + + fun observeVaults(): Flow> = store.map { it.values.toList() } + + private fun Vault.toMetadata() = VaultMetadata( + vaultId = id, + name = name, + icon = icon, + createdAt = createdAt, + count = 0, + ) +} diff --git a/core/security/build.gradle.kts b/core/security/build.gradle.kts index 2995dc75..3d1d1a20 100644 --- a/core/security/build.gradle.kts +++ b/core/security/build.gradle.kts @@ -30,6 +30,10 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + + testFixtures { + enable = true + } } kotlin { @@ -52,7 +56,9 @@ dependencies { implementation(libs.androidx.biometric) + implementation(projects.core.item) api(projects.core.util) + api(projects.rust) // Koin DI @@ -62,6 +68,17 @@ dependencies { testImplementation(libs.kotlin.test) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.io.mockk) + testImplementation(testFixtures(projects.rust)) + + testFixturesApi(projects.core.item) + testFixturesApi(projects.rust) + testFixturesImplementation(libs.kotlinx.coroutines.core) + testFixturesImplementation(testFixtures(projects.rust)) + testFixturesImplementation(project.dependencies.platform(libs.androidx.compose.bom)) + testFixturesImplementation(libs.androidx.compose.runtime) { + because("https://issuetracker.google.com/issues/259523353#comment32") + } androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeImpl.kt index 20dcfd1f..b9737cb0 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeImpl.kt @@ -1,47 +1,72 @@ package de.davis.keygo.core.security.data.crypto -import de.davis.keygo.core.security.domain.crypto.CryptographicConstants +import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.security.domain.crypto.CryptographicScope -import de.davis.keygo.core.security.domain.crypto.model.AesKey import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.rust.item.ItemManager +import de.davisalessandro.keygo.rust.EncryptedItemBlob +import de.davisalessandro.keygo.rust.ItemAad +import de.davisalessandro.keygo.rust.ItemKey +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.withContext -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec +import java.nio.ByteBuffer import kotlin.coroutines.CoroutineContext -internal class CryptographicScopeImpl(private val aesKey: AesKey) : CryptographicScope { - +internal class CryptographicScopeImpl( + private val itemKey: ItemKey, + private val itemAad: ItemAad, + private val itemManager: ItemManager, + private val wrapAction: suspend CoroutineScope.() -> KeyInformation, +) : CryptographicScope { override suspend fun ByteArray.encrypt( - context: CoroutineContext + label: String, + context: CoroutineContext, ): CryptographicData = withContext(context) { - if (isEmpty()) return@withContext CryptographicData(this@encrypt, byteArrayOf()) - val cipher = getCipher() - - cipher.init(Cipher.ENCRYPT_MODE, aesKey.key) - val encrypted = cipher.doFinal(this@encrypt) + val ct = itemManager.encryptItemData( + itemKey = itemKey, + data = this@encrypt, + aad = buildDataAad(label), + ) - CryptographicData(encrypted, cipher.iv) + CryptographicData( + data = ct.ciphertext, + iv = ct.nonce, + ) } override suspend fun CryptographicData.decrypt( - context: CoroutineContext + label: String, + context: CoroutineContext, ): ByteArray = withContext(context) { - if (data.isEmpty()) return@withContext data + val blob = EncryptedItemBlob( + ciphertext = data, + nonce = iv, + ) - // If data is set, we require a valid IV - require(iv.size == IV_LENGTH) { "Invalid IV length: ${iv.size}, expected: $IV_LENGTH" } + itemManager.decryptItemData( + itemKey = itemKey, + blob = blob, + aad = buildDataAad(label), + ) + } - val cipher = getCipher() - cipher.init(Cipher.DECRYPT_MODE, aesKey.key, GCMParameterSpec(T_LEN, iv)) + override suspend fun wrapCurrentItemKey(context: CoroutineContext): KeyInformation = + withContext(context, block = wrapAction) - cipher.doFinal(data) + // The vault binding is already enforced by the item-key wrap layer (item key wrapped under + // vault key with vault id in AAD), so the data AAD only needs to bind ciphertext to its + // owning item and field. This lets us move an item between vaults by rewrapping the item key + // alone, without touching the encrypted secrets. + private fun buildDataAad(label: String): ByteArray { + val labelBytes = label.toByteArray(Charsets.UTF_8) + return ByteBuffer.allocate(UUID_BYTES + labelBytes.size) + .putLong(itemAad.itemId.mostSignificantBits) + .putLong(itemAad.itemId.leastSignificantBits) + .put(labelBytes) + .array() } - companion object { - const val IV_LENGTH = 12 // 96 bits - const val T_LEN = 128 - - private fun getCipher() = - Cipher.getInstance("${CryptographicConstants.ALGORITHM}/${CryptographicConstants.BLOCK_MODE}/${CryptographicConstants.PADDING_MODE}") + private companion object { + const val UUID_BYTES = 16 } -} \ No newline at end of file +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt index 9c571f8f..6468f159 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/data/crypto/CryptographicScopeProviderImpl.kt @@ -1,15 +1,115 @@ package de.davis.keygo.core.security.data.crypto +import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.CryptographicScope import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.mapSuccess +import de.davis.keygo.rust.item.ItemManager +import de.davis.keygo.rust.wrap.KeyWrapper +import de.davis.keygo.rust.wrap.unwrapItemKeyWithResult +import de.davis.keygo.rust.wrap.unwrapVaultKeyWithResult +import de.davis.keygo.rust.wrap.wrapItemKeyWithResult +import de.davisalessandro.keygo.rust.ItemAad +import de.davisalessandro.keygo.rust.KeyWrapException +import de.davisalessandro.keygo.rust.WrappedKeyBlob import org.koin.core.annotation.Single @Single internal class CryptographicScopeProviderImpl( - private val session: Session + private val session: Session, + private val itemManager: ItemManager, + private val keyWrapper: KeyWrapper, ) : CryptographicScopeProvider { - override suspend fun scope(block: suspend CryptographicScope.() -> R): R = - CryptographicScopeImpl(session.dek).block() -} \ No newline at end of file + override suspend fun itemScope( + wrappedVaultKeyInformation: WrappedVaultKeyInformation, + wrappedItemKeyInformation: WrappedItemKeyInformation, + block: suspend CryptographicScope.() -> R, + ): R { + val vaultKey = unwrapVaultKeyWithResult(wrappedVaultKeyInformation).get() + + val itemKey = wrappedItemKeyInformation.wrappedItemKey?.let { + keyWrapper.unwrapItemKeyWithResult( + vaultKey = vaultKey, + wrapped = it.toWrappedKeyBlob(), + aad = wrappedItemKeyInformation.itemAad + ).get() + } ?: itemManager.createNewItemKey() + + return CryptographicScopeImpl( + itemKey = itemKey, + itemAad = wrappedItemKeyInformation.itemAad, + itemManager = itemManager, + wrapAction = { + keyWrapper.wrapItemKey( + vaultKey = vaultKey, + itemKey = itemKey, + aad = wrappedItemKeyInformation.itemAad, + ).toKeyInformation() + }, + ).block() + } + + override suspend fun rewrapItemKey( + sourceVault: WrappedVaultKeyInformation, + sourceItem: WrappedItemKeyInformation, + destinationVault: WrappedVaultKeyInformation, + ): Result { + val wrappedItemKey = requireNotNull(sourceItem.wrappedItemKey) { + "rewrapItemKey requires an existing wrapped item key" + } + val destinationAad = ItemAad( + itemId = sourceItem.itemAad.itemId, + vaultId = destinationVault.vaultId, + ) + + val sourceVaultKey = when (val r = unwrapVaultKeyWithResult(sourceVault)) { + is Result.Success -> r.success + is Result.Failure -> return Result.Failure(r.error) + } + val itemKey = when ( + val r = keyWrapper.unwrapItemKeyWithResult( + vaultKey = sourceVaultKey, + wrapped = wrappedItemKey.toWrappedKeyBlob(), + aad = sourceItem.itemAad, + ) + ) { + is Result.Success -> r.success + is Result.Failure -> return Result.Failure(r.error) + } + + val destinationVaultKey = when (val r = unwrapVaultKeyWithResult(destinationVault)) { + is Result.Success -> r.success + is Result.Failure -> return Result.Failure(r.error) + } + return keyWrapper.wrapItemKeyWithResult( + vaultKey = destinationVaultKey, + itemKey = itemKey, + aad = destinationAad, + ).mapSuccess { it.toKeyInformation() } + } + + private fun unwrapVaultKeyWithResult(info: WrappedVaultKeyInformation) = + keyWrapper.unwrapVaultKeyWithResult( + ark = session.dek.key.encoded, + wrapped = info.wrappedVaultKey.toWrappedKeyBlob(), + vaultId = info.vaultId, + ) +} + +private fun KeyInformation.toWrappedKeyBlob() = WrappedKeyBlob( + ciphertext = wrappedKey, + nonce = keyNonce +) + +private fun WrappedKeyBlob.toKeyInformation(): KeyInformation = + KeyInformation(wrappedKey = ciphertext, keyNonce = nonce) + +private fun Result.get(): S = when (this) { + is Result.Success -> success + is Result.Failure -> throw error +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScope.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScope.kt index 54cbdd9e..9c0d69be 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScope.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScope.kt @@ -1,11 +1,21 @@ package de.davis.keygo.core.security.domain.crypto +import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.security.domain.crypto.model.CryptographicData import kotlinx.coroutines.Dispatchers import kotlin.coroutines.CoroutineContext interface CryptographicScope { - suspend fun ByteArray.encrypt(context: CoroutineContext = Dispatchers.Default): CryptographicData - suspend fun CryptographicData.decrypt(context: CoroutineContext = Dispatchers.Default): ByteArray -} \ No newline at end of file + suspend fun ByteArray.encrypt( + label: String, + context: CoroutineContext = Dispatchers.Default, + ): CryptographicData + + suspend fun CryptographicData.decrypt( + label: String, + context: CoroutineContext = Dispatchers.Default, + ): ByteArray + + suspend fun wrapCurrentItemKey(context: CoroutineContext = Dispatchers.Default): KeyInformation +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt index c7ac8935..f27ba834 100644 --- a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/CryptographicScopeProvider.kt @@ -1,5 +1,30 @@ package de.davis.keygo.core.security.domain.crypto +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.KeyWrapException + interface CryptographicScopeProvider { - suspend fun scope(block: suspend CryptographicScope.() -> R): R -} \ No newline at end of file + + suspend fun itemScope( + wrappedVaultKeyInformation: WrappedVaultKeyInformation, + wrappedItemKeyInformation: WrappedItemKeyInformation, + block: suspend CryptographicScope.() -> R, + ): R + + /** + * Re-wraps an item's key from one vault to another without exposing the unwrapped item key + * or touching the item's encrypted secrets. The destination AAD reuses the item id but is + * bound to [destinationVault]'s id. + * + * Returns [Result.Failure] with the underlying [KeyWrapException] when any of the + * unwrap/wrap steps fail (e.g. corrupted blob, wrong key, invalid AAD). + */ + suspend fun rewrapItemKey( + sourceVault: WrappedVaultKeyInformation, + sourceItem: WrappedItemKeyInformation, + destinationVault: WrappedVaultKeyInformation, + ): Result +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/ItemCryptoBindings.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/ItemCryptoBindings.kt new file mode 100644 index 00000000..76db23f4 --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/ItemCryptoBindings.kt @@ -0,0 +1,12 @@ +package de.davis.keygo.core.security.domain.crypto + +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davisalessandro.keygo.rust.ItemAad + +fun Item.itemAad(): ItemAad = ItemAad(itemId = id, vaultId = vaultId) + +fun Item.wrappedItemKeyInformation(): WrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = itemAad(), + wrappedItemKey = keyInformation +) \ No newline at end of file diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/SecretDataExt.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/SecretDataExt.kt new file mode 100644 index 00000000..355b35da --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/SecretDataExt.kt @@ -0,0 +1,32 @@ +package de.davis.keygo.core.security.domain.crypto + +import de.davis.keygo.core.item.domain.model.SecretData +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +context(scope: CryptographicScope) +suspend fun SecretData.decryptSecretData( + label: String, + ctx: CoroutineContext = Dispatchers.Default, +): T = with(scope) { + decryptedDataType.decode( + CryptographicData(data, iv).decrypt(label = label, context = ctx) + ) +} + +context(scope: CryptographicScope) +suspend inline fun T.encryptSecretData( + label: String, + ctx: CoroutineContext = Dispatchers.Default, +): SecretData = with(scope) { + val decryptedDataType = SecretData.DecryptedDataType.getDecryptedDataType() + val encoded = decryptedDataType.encode(this@encryptSecretData) + + val encryptedData = encoded.encrypt(label = label, context = ctx) + SecretData( + data = encryptedData.data, + iv = encryptedData.iv, + decryptedDataType = decryptedDataType, + ) +} diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/model/KeyInformation.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/model/KeyInformation.kt new file mode 100644 index 00000000..ed1638fb --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/crypto/model/KeyInformation.kt @@ -0,0 +1,16 @@ +package de.davis.keygo.core.security.domain.crypto.model + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davisalessandro.keygo.rust.ItemAad + + +data class WrappedItemKeyInformation( + val itemAad: ItemAad, + val wrappedItemKey: KeyInformation? = null, +) + +data class WrappedVaultKeyInformation( + val wrappedVaultKey: KeyInformation, + val vaultId: VaultId +) diff --git a/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/PasswordWithCryptoScopeUseCase.kt b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/PasswordWithCryptoScopeUseCase.kt new file mode 100644 index 00000000..9d607a6c --- /dev/null +++ b/core/security/src/main/kotlin/de/davis/keygo/core/security/domain/usecase/PasswordWithCryptoScopeUseCase.kt @@ -0,0 +1,54 @@ +package de.davis.keygo.core.security.domain.usecase + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.Item +import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.repository.PasswordRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.security.domain.crypto.CryptographicScope +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single + +@Single +class PasswordWithCryptoScopeUseCase( + private val vaultRepository: VaultRepository, + private val passwordRepository: PasswordRepository, + private val cryptoScopeProvider: CryptographicScopeProvider, +) { + + suspend fun observe( + itemId: ItemId, + block: suspend CryptographicScope.(Password) -> R + ): Flow = passwordRepository.observePasswordById(itemId).map { password -> + password?.let { handleItem(it, block) } + } + + suspend fun oneShot( + itemId: ItemId, + block: suspend CryptographicScope.(Password) -> R, + ): R? { + val password = passwordRepository.getPasswordById(itemId) ?: return null + return handleItem(password, block) + } + + private suspend fun handleItem( + item: I, + block: suspend CryptographicScope.(I) -> R, + ): R? { + val vaultKeyInfo = vaultRepository.getKeyInformation(item.vaultId) ?: return null + + return cryptoScopeProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = vaultKeyInfo, + vaultId = item.vaultId, + ), + wrappedItemKeyInformation = item.wrappedItemKeyInformation() + ) { + block(item) + } + } +} diff --git a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt index 14ece7d8..def321c1 100644 --- a/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt +++ b/core/security/src/test/kotlin/de/davis/keygo/core/security/crypto/CryptographicScopeImplTest.kt @@ -1,164 +1,420 @@ package de.davis.keygo.core.security.crypto -import de.davis.keygo.core.security.data.crypto.CryptographicScopeImpl +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.security.data.crypto.CryptographicScopeProviderImpl +import de.davis.keygo.core.security.domain.Session import de.davis.keygo.core.security.domain.crypto.model.AesKey import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.rust.FakeItemManager +import de.davis.keygo.rust.FakeKeyWrapper +import de.davisalessandro.keygo.rust.ItemAad +import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import java.util.UUID import javax.crypto.spec.SecretKeySpec import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertContentEquals +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull import kotlin.test.assertTrue class CryptographicScopeImplTest { - // Use a fixed seed for reproducibility in tests, - // but a new random each time can also be considered. private val random = Random(42) - private val aesKey by lazy { - // 256-bit fake key for testing only - val keyBytes = ByteArray(32) { random.nextBytes(1)[0] } - val secretKey = SecretKeySpec(keyBytes, "AES") - AesKey(secretKey) + private val sessionDek = AesKey(SecretKeySpec(ByteArray(32) { random.nextBytes(1)[0] }, "AES")) + private val session: Session = mockk { every { dek } returns sessionDek } + private val itemManager = FakeItemManager() + private val keyWrapper = FakeKeyWrapper() + + private val provider = CryptographicScopeProviderImpl(session, itemManager, keyWrapper) + + private val label = "password" + + private fun wrappedVaultKeyInformation( + vaultId: UUID = UUID.randomUUID(), + ): WrappedVaultKeyInformation { + val blob = keyWrapper.wrapVaultKey( + ark = sessionDek.key.encoded, + vaultKey = ByteArray(32) { random.nextBytes(1)[0] }, + vaultId = vaultId, + ) + return WrappedVaultKeyInformation( + wrappedVaultKey = KeyInformation( + wrappedKey = blob.ciphertext, + keyNonce = blob.nonce, + ), + vaultId = vaultId, + ) } - private val scope by lazy { - CryptographicScopeImpl(aesKey) + private fun itemAad(vaultId: UUID = UUID.randomUUID()) = + ItemAad(itemId = UUID.randomUUID(), vaultId = vaultId) + + @Test + fun `round-trips a payload using the same generated item key`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val plaintext = "item secret".toByteArray() + + val (cipher, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + plaintext.encrypt(label) to wrapCurrentItemKey() + } + + val recovered = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = aad, + wrappedItemKey = wrapped, + ), + ) { cipher.decrypt(label) } + + assertContentEquals(plaintext, recovered) } - /** - * 1. Basic round-trip: small data. - */ @Test - fun `test encrypt then decrypt small data returns original data`() = runTest { - val originalData = "Hello, World!".toByteArray() + fun `multiple operations within one scope share the same item key`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val first = "first".toByteArray() + val second = "second".toByteArray() - val encryptedData = scope.run { originalData.encrypt() } - val decryptedData = scope.run { encryptedData.decrypt() } + val (encrypted, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + val a = first.encrypt(label) + val b = second.encrypt(label) + (a to b) to wrapCurrentItemKey() + } - assertContentEquals(originalData, decryptedData) + provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = aad, + wrappedItemKey = wrapped, + ), + ) { + assertContentEquals(first, encrypted.first.decrypt(label)) + assertContentEquals(second, encrypted.second.decrypt(label)) + } } - /** - * 2. Empty data encryption-decryption. - */ @Test - fun `test encrypt empty data`() = runTest { - val originalData = ByteArray(0) + fun `decrypting with the wrong vault key fails`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val plaintext = "secret".toByteArray() - val encryptedData = scope.run { originalData.encrypt() } - val decryptedData = scope.run { encryptedData.decrypt() } + val (cipher, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + plaintext.encrypt(label) to wrapCurrentItemKey() + } - assertContentEquals(originalData, decryptedData) - assertTrue(encryptedData.data.isEmpty(), "Encrypted data should be empty for empty input") + val otherVault = wrappedVaultKeyInformation() + assertFailsWith { + provider.itemScope( + wrappedVaultKeyInformation = otherVault, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = aad, + wrappedItemKey = wrapped, + ), + ) { cipher.decrypt(label) } + } } - /** - * 3. Corrupted data should fail to decrypt. - * We flip one byte in the ciphertext to ensure decryption fails. - */ @Test - fun `test corrupted data throws exception on decrypt`() = runTest { - val originalData = "This data will be corrupted!".toByteArray() - val encryptedData = scope.run { originalData.encrypt() } + fun `decrypting with a different label fails`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val plaintext = "secret".toByteArray() - val corruptedCiphertext = encryptedData.data.clone().apply { - val middleIndex = size / 2 - this[middleIndex] = (this[middleIndex].toInt() xor 0xFF).toByte() + val (cipher, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + plaintext.encrypt(label = "password") to wrapCurrentItemKey() } - val corrupted = CryptographicData(corruptedCiphertext, encryptedData.iv) assertFailsWith { - runBlocking { - scope.run { corrupted.decrypt() } - } + provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = aad, + wrappedItemKey = wrapped, + ), + ) { cipher.decrypt(label = "totp_secret") } } } - /** - * 4. Repeated encryption of the same data should yield different results - * (because each encryption uses a new IV in AES-GCM). - */ @Test - fun `test repeated encryption yields different ciphertext`() = runTest { - val originalData = "Sensitive secret data".toByteArray() - val results = mutableSetOf() + fun `decrypting with a different itemAad under same label fails`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val plaintext = "secret".toByteArray() - repeat(5) { - val encryptedData = scope.run { originalData.encrypt() } - results.add(encryptedData.data.joinToString(separator = ",")) + val (cipher, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + plaintext.encrypt(label) to wrapCurrentItemKey() } - assertTrue( - results.size > 1, - "Repeated encryption of the same data should yield different ciphertext each time" + assertFailsWith { + provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = itemAad(vaultInfo.vaultId), + wrappedItemKey = wrapped, + ), + ) { cipher.decrypt(label) } + } + } + + @Test + fun `unwrapping the item key with the wrong itemAad fails`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val plaintext = "secret".toByteArray() + + val (cipher, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + plaintext.encrypt(label) to wrapCurrentItemKey() + } + + assertFailsWith { + provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = itemAad(vaultInfo.vaultId), + wrappedItemKey = wrapped, + ), + ) { cipher.decrypt(label) } + } + } + + @Test + fun `create flow exposes a non-empty wrapped item key`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + + val wrapped = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = itemAad(vaultInfo.vaultId)), + ) { wrapCurrentItemKey() } + + assertNotNull(wrapped) + assertTrue(wrapped.wrappedKey.isNotEmpty()) + assertTrue(wrapped.keyNonce.isNotEmpty()) + } + + @Test + fun `empty data round-trips`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + + val (cipher, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + ByteArray(0).encrypt(label) to wrapCurrentItemKey() + } + + val recovered = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = aad, + wrappedItemKey = wrapped, + ), + ) { cipher.decrypt(label) } + + assertContentEquals(ByteArray(0), recovered) + } + + @Test + fun `repeated encryption yields unique ciphertexts and IVs`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val plaintext = "Sensitive secret data".toByteArray() + val attempts = 5 + + val ciphertexts = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + List(attempts) { plaintext.encrypt(label) } + } + + val uniqueCiphertexts = ciphertexts.map { it.data.contentToString() }.toSet() + val uniqueIvs = ciphertexts.map { it.iv.contentToString() }.toSet() + + assertEquals( + attempts, + uniqueIvs.size, + "Expected $attempts unique IVs. The IV generation might be deterministic or repeating." + ) + + assertEquals( + attempts, + uniqueCiphertexts.size, + "Expected $attempts unique ciphertexts. Encryption is not properly randomized." ) } - /** - * 5. Concurrency: encrypt and decrypt in parallel coroutines. - * We do multiple round-trips simultaneously and ensure correctness. - */ @Test - fun `test concurrent encryption and decryption`() = runTest { - val originalData = "Concurrent encryption test".toByteArray() - val jobs = mutableListOf>() + fun `different labels yield different ciphertext for same plaintext`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val plaintext = "Same data, different field".toByteArray() - repeat(10) { - jobs.add(async { - val encryptedData = scope.run { originalData.encrypt() } - val decryptedData = scope.run { encryptedData.decrypt() } - // Return whether they match - originalData.contentEquals(decryptedData) - }) + val (passwordCipher, totpCipher, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + Triple( + plaintext.encrypt(label = "password"), + plaintext.encrypt(label = "totp_secret"), + wrapCurrentItemKey(), + ) + } + + assertTrue(!passwordCipher.data.contentEquals(totpCipher.data)) + + val (recoveredPassword, recoveredTotp) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = aad, + wrappedItemKey = wrapped, + ), + ) { + passwordCipher.decrypt(label = "password") to totpCipher.decrypt(label = "totp_secret") } - val results = jobs.awaitAll() + assertContentEquals(plaintext, recoveredPassword) + assertContentEquals(plaintext, recoveredTotp) + } + + @Test + fun `tampered ciphertext fails to decrypt`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val plaintext = "This data will be corrupted!".toByteArray() + + val (cipher, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + plaintext.encrypt(label) to wrapCurrentItemKey() + } + + val tampered = CryptographicData( + data = cipher.data.clone().apply { + val middle = size / 2 + this[middle] = (this[middle].toInt() xor 0xFF).toByte() + }, + iv = cipher.iv, + ) + + assertFailsWith { + provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = aad, + wrappedItemKey = wrapped, + ), + ) { tampered.decrypt(label) } + } + } + + @Test + fun `concurrent encrypt and decrypt succeeds`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val plaintext = "Concurrent encryption test".toByteArray() + + val results = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + val jobs: List> = List(10) { + async { + val encrypted = plaintext.encrypt(label) + val decrypted = encrypted.decrypt(label) + plaintext.contentEquals(decrypted) + } + } + jobs.awaitAll() + } assertTrue( results.all { it }, - "All concurrent encrypt/decrypt operations should return original data" + "All concurrent encrypt/decrypt operations should return original data", ) } - /** - * 6. Large data encryption-decryption test (~1MB). - */ @Test - fun `test large data encryption and decryption`() = runTest { - // 1 MB of random data - val originalData = ByteArray(1_000_000) { random.nextBytes(1)[0] } + fun `large data round-trips`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + val aad = itemAad(vaultInfo.vaultId) + val plaintext = ByteArray(1_000_000) { random.nextBytes(1)[0] } + + val (cipher, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + plaintext.encrypt(label) to wrapCurrentItemKey() + } - val encryptedData = scope.run { originalData.encrypt() } - val decryptedData = scope.run { encryptedData.decrypt() } + val recovered = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = aad, + wrappedItemKey = wrapped, + ), + ) { cipher.decrypt(label) } - // Then - assertContentEquals(originalData, decryptedData) + assertContentEquals(plaintext, recovered) } - /** - * 7. Random data (variable lengths) test. - * For a bit more coverage, we can test 10 random sizes up to 65KB or so. - */ @Test - fun `test random data encrypt decrypt multiple sizes`() = runTest { + fun `random sized payloads round-trip`() = runTest { + val vaultInfo = wrappedVaultKeyInformation() + repeat(10) { - val size = random.nextInt(1, 65_536) // up to 64KB - val original = ByteArray(size) { random.nextBytes(1)[0] } - val encrypted = scope.run { original.encrypt() } - val decrypted = scope.run { encrypted.decrypt() } - - assertContentEquals( - original, - decrypted, - "Random data of size=$size should match after decrypt" - ) + val aad = itemAad(vaultInfo.vaultId) + val size = random.nextInt(1, 65_536) + val plaintext = ByteArray(size) { random.nextBytes(1)[0] } + + val (cipher, wrapped) = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + plaintext.encrypt(label) to wrapCurrentItemKey() + } + + val recovered = provider.itemScope( + wrappedVaultKeyInformation = vaultInfo, + wrappedItemKeyInformation = WrappedItemKeyInformation( + itemAad = aad, + wrappedItemKey = wrapped, + ), + ) { cipher.decrypt(label) } + + assertContentEquals(plaintext, recovered, "size=$size should round-trip") } } -} \ No newline at end of file +} diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt new file mode 100644 index 00000000..4f765108 --- /dev/null +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/BindingCryptographicScopeProvider.kt @@ -0,0 +1,26 @@ +package de.davis.keygo.core.security.crypto + +import de.davis.keygo.core.security.data.crypto.CryptographicScopeProviderImpl +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davisalessandro.keygo.rust.ItemManagerInterface +import de.davisalessandro.keygo.rust.KeyWrapperInterface + +/** + * Constructs the production [CryptographicScopeProvider] backed by the supplied fakes. + * + * Use when a test depends on the AAD-binding semantics — ciphertext bound to + * (vaultId, itemId, label), item keys wrapped under the vault key. For tests + * that only need a deterministic round-trip with no AAD enforcement, + * [FakeCryptographicScopeProvider] is simpler. + */ +@Suppress("TestFunctionName") +fun BindingCryptographicScopeProvider( + session: Session, + itemManager: ItemManagerInterface, + keyWrapper: KeyWrapperInterface, +): CryptographicScopeProvider = CryptographicScopeProviderImpl( + session = session, + itemManager = itemManager, + keyWrapper = keyWrapper, +) diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt new file mode 100644 index 00000000..88b607cc --- /dev/null +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeCryptographicScopeProvider.kt @@ -0,0 +1,86 @@ +package de.davis.keygo.core.security.crypto + +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.security.domain.crypto.CryptographicScope +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.model.CryptographicData +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.KeyWrapException +import kotlin.coroutines.CoroutineContext +import kotlin.experimental.xor + +class FakeCryptographicScopeProvider : CryptographicScopeProvider { + + sealed interface CallHistory { + + class EncryptCall(val label: String, val plaintext: ByteArray) : CallHistory + class DecryptCall(val label: String, val data: ByteArray) : CallHistory + class RewrapCall( + val sourceVault: WrappedVaultKeyInformation, + val sourceItem: WrappedItemKeyInformation, + val destinationVault: WrappedVaultKeyInformation, + ) : CallHistory + } + + val callHistory = mutableListOf() + val encryptCalls + get() = callHistory.filterIsInstance() + val rewrapCalls + get() = callHistory.filterIsInstance() + + /** Result returned by the next [rewrapItemKey] call. */ + var rewrapResult: Result = + Result.Success(KeyInformation(byteArrayOf(), byteArrayOf())) + + override suspend fun itemScope( + wrappedVaultKeyInformation: WrappedVaultKeyInformation, + wrappedItemKeyInformation: WrappedItemKeyInformation, + block: suspend CryptographicScope.() -> R, + ): R = block( + object : CryptographicScope { + override suspend fun ByteArray.encrypt( + label: String, + context: CoroutineContext, + ): CryptographicData { + callHistory += CallHistory.EncryptCall(label, this.copyOf()) + return CryptographicData(data = transform(this), iv = IV) + } + + override suspend fun CryptographicData.decrypt( + label: String, + context: CoroutineContext, + ): ByteArray { + callHistory += CallHistory.DecryptCall(label, this.data.copyOf()) + return transform(data) + } + + override suspend fun wrapCurrentItemKey(context: CoroutineContext): KeyInformation = + KeyInformation(byteArrayOf(), byteArrayOf()) + } + ) + + override suspend fun rewrapItemKey( + sourceVault: WrappedVaultKeyInformation, + sourceItem: WrappedItemKeyInformation, + destinationVault: WrappedVaultKeyInformation, + ): Result { + callHistory += CallHistory.RewrapCall(sourceVault, sourceItem, destinationVault) + return rewrapResult + } + + companion object { + val IV: ByteArray = byteArrayOf(0x01, 0x02, 0x03, 0x04) + private val KEY: ByteArray = byteArrayOf(0xCA.toByte(), 0xFE.toByte()) + + /** + * Performs XOR operation between the input and a fixed key. + */ + fun transform(data: ByteArray): ByteArray { + return ByteArray(data.size) { i -> + data[i] xor KEY[i % KEY.size] + } + } + } +} \ No newline at end of file diff --git a/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt new file mode 100644 index 00000000..c3676336 --- /dev/null +++ b/core/security/src/testFixtures/kotlin/de/davis/keygo/core/security/crypto/FakeSession.kt @@ -0,0 +1,17 @@ +package de.davis.keygo.core.security.crypto + +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.security.domain.crypto.model.AesKey +import javax.crypto.spec.SecretKeySpec + +/** + * A fake implementation of [Session] that provides a fixed DEK for testing purposes. + */ +class FakeSession : Session { + + override val dek: AesKey + get() = AesKey(SecretKeySpec(ByteArray(32) { it.toByte() }, "AES")) + + override fun startSession(dek: AesKey) = Unit + override fun endSession() = Unit +} \ No newline at end of file diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt new file mode 100644 index 00000000..8fe56909 --- /dev/null +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoSwitch.kt @@ -0,0 +1,58 @@ +package de.davis.keygo.core.ui.components + +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ListItemShapes +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun KeyGoSwitch( + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + overlineContent: @Composable (() -> Unit)? = null, + supportingContent: @Composable (() -> Unit)? = null, + leadingContent: @Composable (() -> Unit)? = null, + colors: ListItemColors = ListItemDefaults.colors(), + shapes: ListItemShapes = ListItemDefaults.shapes(), + content: @Composable () -> Unit +) { + ListItem( + checked = checked, + modifier = modifier, + onCheckedChange = onCheckedChange, + overlineContent = overlineContent, + supportingContent = supportingContent, + leadingContent = leadingContent, + trailingContent = { + Switch( + checked = checked, + onCheckedChange = null, + thumbContent = { + Icon( + imageVector = when { + checked -> Icons.Default.Check + else -> Icons.Default.Close + }, + contentDescription = null, + modifier = Modifier.size(SwitchDefaults.IconSize), + ) + } + ) + }, + colors = colors, + shapes = shapes, + content = content, + ) +} \ No newline at end of file diff --git a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt index bd45980e..56a1a943 100644 --- a/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt +++ b/feature/auth/src/main/kotlin/de/davis/keygo/feature/auth/presentation/AuthViewModel.kt @@ -6,14 +6,13 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute -import de.davis.keygo.core.identity.domain.repository.WrappedKeyRepository +import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.core.identity.domain.usecase.CreateAccessUseCase import de.davis.keygo.core.identity.domain.usecase.UnlockWithPasswordUseCase import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator import de.davis.keygo.core.security.domain.repository.BiometricAvailabilityRepository import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.asResult -import de.davis.keygo.core.util.isSuccess import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess import de.davis.keygo.feature.auth.presentation.model.AuthState @@ -47,7 +46,7 @@ import kotlin.time.Duration.Companion.milliseconds internal class AuthViewModel( savedStateHandle: SavedStateHandle, biometricAvailabilityRepository: BiometricAvailabilityRepository, - wrappedKeyRepository: WrappedKeyRepository, + accountRepository: AccountRepository, // ---- Migration ---- hasV1MainPassword: HasMainPasswordUseCase, @@ -72,13 +71,14 @@ internal class AuthViewModel( init { viewModelScope.launch { - val hasAccess = wrappedKeyRepository.getPasswordWrappedKey().isSuccess() + val activeAccount = accountRepository.getOrNull() + val hasAccess = activeAccount != null val hasAccessButShouldMigrate = if (!hasAccess) hasV1MainPassword() else false val isBiometricHardwareAvailable = biometricAvailabilityRepository.availability() val isBiometricCryptoSetupAvailable = - hasAccess && wrappedKeyRepository.getBiometricWrappedKey().isSuccess() + hasAccess && activeAccount.biometricWrappedArk != null val biometricsUsable = isBiometricHardwareAvailable && isBiometricCryptoSetupAvailable if (biometricsUsable && authRoute.showBiometricPromptIfPossible) requestBiometricLogin() diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToPasswordUseCase.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToPasswordUseCase.kt index 2ceda0e6..6db4332d 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToPasswordUseCase.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/domain/usecase/AddRegistrableDomainsToPasswordUseCase.kt @@ -12,12 +12,13 @@ class AddRegistrableDomainsToPasswordUseCase( private val registrableDomainResolver: RegistrableDomainResolver ) { - suspend operator fun invoke(vaultItemId: ItemId, domain: String) { + suspend operator fun invoke(passwordId: ItemId, domain: String) { val domainInfo = DomainInfo( + passwordId = passwordId, value = domain, eTLD1 = registrableDomainResolver.resolve(domain) ) - passwordRepository.updatePasswordWithDomainInfo(vaultItemId, setOf(domainInfo)) + passwordRepository.updateDomainInfos(passwordId, setOf(domainInfo)) } } \ No newline at end of file diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/KeyGoAutofillService.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/KeyGoAutofillService.kt index a6904e06..acfd98e3 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/KeyGoAutofillService.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/KeyGoAutofillService.kt @@ -12,8 +12,7 @@ import android.service.autofill.SaveRequest import android.util.Log import android.view.autofill.AutofillId import androidx.core.os.bundleOf -import de.davis.keygo.core.identity.domain.repository.WrappedKeyRepository -import de.davis.keygo.core.util.isSuccess +import de.davis.keygo.core.identity.domain.repository.AccountRepository import de.davis.keygo.feature.autofill.presentation.dataset.applySaveInfo import de.davis.keygo.feature.autofill.presentation.dataset.getForm import de.davis.keygo.feature.autofill.presentation.model.Form @@ -29,7 +28,7 @@ internal class KeyGoAutofillService : AutofillService() { private val extractor by inject() private val datasetProvider by inject() - private val wrappedKeyRepository by inject() + private val accountRepository by inject() override fun onFillRequest( request: FillRequest, @@ -42,7 +41,7 @@ internal class KeyGoAutofillService : AutofillService() { } val job = CoroutineScope(Dispatchers.IO + handler).launch { - if (!wrappedKeyRepository.getPasswordWrappedKey().isSuccess()) { + if (accountRepository.getOrNull() == null) { Log.w(TAG, "No valid access - not filling") callback.onSuccess(null) return@launch diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt index da2b48e4..e36265e2 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/activity/AutofillViewModel.kt @@ -6,11 +6,14 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.crypto.decryptSecretData import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.repository.ItemRepository import de.davis.keygo.core.item.domain.repository.PasswordRepository -import de.davis.keygo.core.item.domain.repository.VaultItemRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.decryptSecretData +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation import de.davis.keygo.feature.autofill.domain.usecase.AddRegistrableDomainsToPasswordUseCase import de.davis.keygo.feature.autofill.domain.usecase.DoesItemHaveDomainReferencesUseCase import de.davis.keygo.feature.autofill.domain.usecase.IsAppLinkedToWebsiteUseCase @@ -43,8 +46,9 @@ import org.koin.core.annotation.KoinViewModel @KoinViewModel internal class AutofillViewModel( savedStateHandle: SavedStateHandle, + private val vaultRepository: VaultRepository, private val passwordRepository: PasswordRepository, - private val vaultItemRepository: VaultItemRepository, + private val itemRepository: ItemRepository, private val cryptographicScopeProvider: CryptographicScopeProvider, private val autofillDatasetProvider: AutofillDatasetProvider, private val doesItemHaveDomainReferences: DoesItemHaveDomainReferencesUseCase, @@ -134,9 +138,9 @@ internal class AutofillViewModel( } private suspend fun handleSuggestionRequest(suggestionInfo: FillRequestData.Suggestion) { - _uiState.update { it.copy(vaultId = suggestionInfo.vaultId) } + _uiState.update { it.copy(itemId = suggestionInfo.vaultId) } - val itemName = vaultItemRepository.getItemName(suggestionInfo.vaultId) + val itemName = itemRepository.getItemName(suggestionInfo.vaultId) ?: throw IllegalArgumentException("Name for vaultId=${suggestionInfo.vaultId} not found") biometricChannel.send(AutofillBiometricRequest.UnlockItem(itemName)) @@ -169,7 +173,7 @@ internal class AutofillViewModel( return@launch } - val itemName = vaultItemRepository.getItemName(vaultId) + val itemName = itemRepository.getItemName(vaultId) ?: throw IllegalArgumentException("Name for vaultId=$vaultId not found") _uiState.update { @@ -178,7 +182,7 @@ internal class AutofillViewModel( itemName = itemName, domain = requestData.form.url ), - vaultId = vaultId + itemId = vaultId ) } } @@ -222,13 +226,14 @@ internal class AutofillViewModel( } private fun associateItem() { - val vaultItemId = uiState.value.vaultId - requestData.form.url?.let { - viewModelScope.launch { - addRegistrableDomainToPassword( - vaultItemId = vaultItemId, - domain = it - ) + uiState.value.itemId?.let { itemId -> + requestData.form.url?.let { + viewModelScope.launch { + addRegistrableDomainToPassword( + passwordId = itemId, + domain = it + ) + } } } hideAssociationDialog() @@ -236,12 +241,14 @@ internal class AutofillViewModel( private fun hideAssociationDialog() { _uiState.update { it.copy(associationDialogVisibility = AssociationDialogVisibility.Hidden) } - viewModelScope.launch { - sendFillEvent(_uiState.value.vaultId) + _uiState.value.itemId?.let { itemId -> + viewModelScope.launch { + sendFillEvent(itemId) + } } } - private suspend fun sendFillEvent(vaultId: ItemId) { + private suspend fun sendFillEvent(itemId: ItemId) { if (requestData !is FillRequestData) { eventChannel.send(AutofillEvent.Abort) return @@ -250,7 +257,7 @@ internal class AutofillViewModel( val formInformation = requestData.form when (formInformation.type) { is FormType.Credentials -> { - val password = passwordRepository.getPasswordById(vaultId) ?: run { + val password = passwordRepository.getPasswordById(itemId) ?: run { eventChannel.send(AutofillEvent.Abort) return } @@ -266,14 +273,26 @@ internal class AutofillViewModel( return } - val password = passwordRepository.getPasswordById(vaultId) ?: run { + // TODO: dont fetch entire password -> just fetch the totp field + val password = passwordRepository.getPasswordById(itemId) ?: run { + eventChannel.send(AutofillEvent.Abort) + return + } + + val wrappedVaultKey = vaultRepository.getKeyInformation(password.vaultId) ?: run { eventChannel.send(AutofillEvent.Abort) return } val totp = password.totpSecret?.let { - val secret = cryptographicScopeProvider.scope { - it.decryptSecretData().encodeToByteArray() + val secret = cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = wrappedVaultKey, + vaultId = password.vaultId + ), + wrappedItemKeyInformation = password.wrappedItemKeyInformation() + ) { + it.decryptSecretData(label = Password.LABEL_TOTP_SECRET).encodeToByteArray() } totpGenerator.observeTotp(secret).first() } ?: run { @@ -298,12 +317,23 @@ internal class AutofillViewModel( } private suspend fun sendPasswordFillEvent(password: Password) { + val wrappedVaultKey = vaultRepository.getKeyInformation(password.vaultId) ?: run { + eventChannel.send(AutofillEvent.Abort) + return + } + val values = requestData.form.fields.mapNotNull { val type = it.type if (type !is FieldType.Credentials) return@mapNotNull null val value = when (type) { - FieldType.Credentials.Password -> cryptographicScopeProvider.scope { - password.encryptedData.decryptSecretData() + FieldType.Credentials.Password -> cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = wrappedVaultKey, + vaultId = password.vaultId + ), + wrappedItemKeyInformation = password.wrappedItemKeyInformation() + ) { + password.password.decryptSecretData(label = Password.LABEL_PASSWORD) } FieldType.Credentials.Username -> password.username diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt index dacd6a0a..86d754a5 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/inline/InlineDatasetBuilder.kt @@ -178,7 +178,7 @@ internal class InlineDatasetBuilder( return presentation.buildDataset( suggestionRequestData( form, - suggestion.vaultItemId, + suggestion.id, index ) ) diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt index 842c6c29..40ef8b44 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/dataset/menu/MenuDatasetBuilder.kt @@ -94,7 +94,7 @@ internal class MenuDatasetBuilder( intentSender = context.getSelectionPendingIntent( suggestionRequestData( form, - suggestion.vaultItemId, + suggestion.id, index ) ).intentSender, diff --git a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/AutofillUiState.kt b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/AutofillUiState.kt index ccfdf8cd..bd1d7cc4 100644 --- a/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/AutofillUiState.kt +++ b/feature/autofill/src/main/kotlin/de/davis/keygo/feature/autofill/presentation/model/AutofillUiState.kt @@ -1,7 +1,6 @@ package de.davis.keygo.feature.autofill.presentation.model import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.alias.ItemIdNone import de.davis.keygo.feature.autofill.presentation.activity.model.AssociationDialogVisibility import de.davis.keygo.feature.autofill.presentation.activity.model.SuspicionDialogVisibility @@ -10,5 +9,5 @@ internal data class AutofillUiState( val associationDialogVisibility: AssociationDialogVisibility = AssociationDialogVisibility.Hidden, val suspicionDialogVisibility: SuspicionDialogVisibility = SuspicionDialogVisibility.Hidden, val showGeneratePassword: Boolean = false, - val vaultId: ItemId = ItemIdNone + val itemId: ItemId? = null ) \ No newline at end of file diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt index 29566da0..28ad426f 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/create/activity/CreatePasskeyViewModel.kt @@ -13,7 +13,9 @@ import de.davis.keygo.core.item.domain.repository.PasswordRepository import de.davis.keygo.core.security.domain.model.CiphertextData import de.davis.keygo.core.util.getOrNull import de.davis.keygo.rust.passkey.PasskeyManager -import de.davis.keygo.rust.passkey.model.KeyGoRegistrationResponse +import de.davis.keygo.rust.passkey.getExcludedCredentialIds +import de.davis.keygo.rust.passkey.registerWithResult +import de.davisalessandro.keygo.rust.RegistrationResponse import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -23,6 +25,7 @@ import org.koin.core.annotation.KoinViewModel internal class CreatePasskeyViewModel( private val passkeyRepository: PasskeyRepository, private val passwordRepository: PasswordRepository, + private val passkeyManager: PasskeyManager, ) : ViewModel() { private val biometricChannel = Channel() @@ -31,21 +34,21 @@ internal class CreatePasskeyViewModel( private val _event = Channel() val event = _event.receiveAsFlow() - private var registrationResponse: KeyGoRegistrationResponse? = null + private var registrationResponse: RegistrationResponse? = null private var key: CiphertextData? = null fun updateCreatePublicKeyCredentialRequest(request: CreatePublicKeyCredentialRequest) { viewModelScope.launch { val idsToExclude = - PasskeyManager.getExcludedCredentialIds(request.requestJson).getOrNull() + passkeyManager.getExcludedCredentialIds(request.requestJson).getOrNull() ?.toSet() ?: return@launch abort("Failed to get excluded IDs") val shouldAbort = passkeyRepository.doCredentialIdsExist(idsToExclude) if (shouldAbort) return@launch abort("Credential ID already exists") - registrationResponse = PasskeyManager.register(request.requestJson) + registrationResponse = passkeyManager.registerWithResult(request.requestJson) .getOrNull() ?: return@launch abort("Failed to register passkey") // Request authentication @@ -71,9 +74,6 @@ internal class CreatePasskeyViewModel( registrationResponse ?: return@launch abort("Response was null") val key = key ?: return@launch abort("Key was null") - val passwordId = passwordRepository.getPasswordIdByVaultId(itemId) - ?: return@launch abort("No password found for vault ID") - val passkey = Passkey( credentialId = registrationResponse.credentialId, privateKey = SecretData( @@ -81,8 +81,8 @@ internal class CreatePasskeyViewModel( iv = key.iv, decryptedDataType = SecretData.DecryptedDataType.StringType ), - rp = registrationResponse.rpId, - passwordId = passwordId, + rp = registrationResponse.rp, + passwordId = itemId, user = PasskeyUser( name = registrationResponse.userName, displayName = registrationResponse.userDisplayName @@ -90,7 +90,7 @@ internal class CreatePasskeyViewModel( ) passkeyRepository.createPasskey(passkey) - _event.send(CreatePasskeyEvent.Finish(registrationResponse.responseJson)) + _event.send(CreatePasskeyEvent.Finish(registrationResponse.response)) } } @@ -99,7 +99,7 @@ internal class CreatePasskeyViewModel( CreatePasskeyEvent.OpenConfirmationDialog( itemId = itemId, itemName = "N/A", - rp = registrationResponse?.rpId ?: "N/A" + rp = registrationResponse?.rp ?: "N/A" ) ) } diff --git a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyViewModel.kt b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyViewModel.kt index 34b25ca7..8d05182d 100644 --- a/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyViewModel.kt +++ b/feature/credentials/src/main/kotlin/de/davis/keygo/feature/credentials/presentation/provide/activity/ProvidePasskeyViewModel.kt @@ -9,6 +9,7 @@ import de.davis.keygo.core.security.domain.model.CiphertextData import de.davis.keygo.core.util.onFailure import de.davis.keygo.core.util.onSuccess import de.davis.keygo.rust.passkey.PasskeyManager +import de.davis.keygo.rust.passkey.authenticateWithResult import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -16,7 +17,8 @@ import org.koin.core.annotation.KoinViewModel @KoinViewModel internal class ProvidePasskeyViewModel( - private val passkeyRepository: PasskeyRepository + private val passkeyRepository: PasskeyRepository, + private val passkeyManager: PasskeyManager ) : ViewModel() { private val biometricChannel = Channel() @@ -55,7 +57,7 @@ internal class ProvidePasskeyViewModel( viewModelScope.launch { val requestJson = requestJson ?: return@launch abort("Request was null") val clientHashData = clientHashData ?: return@launch abort("ClientHashData was null") - PasskeyManager.authenticate( + passkeyManager.authenticateWithResult( requestJson = requestJson, passkey = key, clientDataHash = clientHashData diff --git a/feature/item/core/build.gradle.kts b/feature/item/core/build.gradle.kts index eb6baf41..58f8ff10 100644 --- a/feature/item/core/build.gradle.kts +++ b/feature/item/core/build.gradle.kts @@ -31,6 +31,10 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + + testFixtures { + enable = true + } } kotlin { @@ -59,6 +63,12 @@ dependencies { implementation(libs.koin.androidx.compose) implementation(libs.koin.annotations) + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.rust)) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } \ No newline at end of file diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/PasswordError.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/PasswordError.kt index ecc6dd76..b605bd2f 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/PasswordError.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/PasswordError.kt @@ -4,5 +4,6 @@ sealed interface PasswordError { data object BlankName : PasswordError data object BlankPassword : PasswordError data object InvalidVaultId : PasswordError + data object InvalidItemId : PasswordError data class DatabaseError(val throwable: Throwable) : PasswordError } \ No newline at end of file diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertPassword.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertPassword.kt index 77fe5afb..6779d9ad 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertPassword.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertPassword.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.item.core.domain.model import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.DomainInfo @ConsistentCopyVisibility @@ -15,6 +16,7 @@ data class UpsertPassword private constructor( ) { companion object { fun create( + vaultId: VaultId, name: String, password: String, totpSecret: String? = null, @@ -22,7 +24,7 @@ data class UpsertPassword private constructor( domains: Set = emptySet(), note: String? = null, ) = UpsertPassword( - upsertType = UpsertType.Create, + upsertType = UpsertType.Create(vaultId), name = FieldUpdate.Set(name), password = FieldUpdate.Set(password), note = if (!note.isNullOrBlank()) FieldUpdate.Set(note) else FieldUpdate.Clear, @@ -32,7 +34,8 @@ data class UpsertPassword private constructor( ) fun update( - vaultId: ItemId, + itemId: ItemId, + vaultId: VaultId? = null, name: FieldUpdate = keep(), password: FieldUpdate = keep(), totpSecret: FieldUpdate = keep(), @@ -40,7 +43,7 @@ data class UpsertPassword private constructor( domains: FieldUpdate> = keep(), note: FieldUpdate = keep(), ) = UpsertPassword( - upsertType = UpsertType.Update(vaultId), + upsertType = UpsertType.Update(itemId, vaultId), name = name, password = password, note = note, diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertType.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertType.kt index fcf5ec20..5b8b9683 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertType.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/model/UpsertType.kt @@ -1,8 +1,9 @@ package de.davis.keygo.feature.item.core.domain.model import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId sealed interface UpsertType { - data object Create : UpsertType - data class Update(val vaultItemId: ItemId) : UpsertType + data class Create(val vaultId: VaultId) : UpsertType + data class Update(val id: ItemId, val targetVaultId: VaultId? = null) : UpsertType } \ No newline at end of file diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCase.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCase.kt index 8f3d062e..f759948f 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCase.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCase.kt @@ -1,12 +1,20 @@ package de.davis.keygo.feature.item.core.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.crypto.encryptSecretData +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.model.Password.Companion.LABEL_PASSWORD +import de.davis.keygo.core.item.domain.model.Password.Companion.LABEL_TOTP_SECRET import de.davis.keygo.core.item.domain.repository.PasswordRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.encryptSecretData +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation import de.davis.keygo.core.util.Result import de.davis.keygo.core.util.mapFailure import de.davis.keygo.feature.item.core.domain.model.FieldUpdate @@ -17,6 +25,7 @@ import de.davis.keygo.feature.item.core.domain.model.getValue import de.davis.keygo.feature.item.core.domain.model.on import de.davis.keygo.feature.item.core.domain.model.onSet import de.davis.keygo.feature.item.core.domain.model.withoutClearingOn +import de.davisalessandro.keygo.rust.ItemAad import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import org.koin.core.annotation.Single @@ -26,6 +35,7 @@ import kotlin.contracts.ExperimentalContracts class CreateNewOrUpdatePasswordUseCase( private val cryptographicScopeProvider: CryptographicScopeProvider, private val passwordRepository: PasswordRepository, + private val vaultRepository: VaultRepository, private val upsertVaultItem: UpsertVaultItemUseCase, private val passwordStrengthEstimator: PasswordStrengthEstimator ) { @@ -51,68 +61,146 @@ class CreateNewOrUpdatePasswordUseCase( return errors } - suspend operator fun invoke(upsert: UpsertPassword): Result> = - coroutineScope { - val errors = validate(upsert) - if (errors.isNotEmpty()) - return@coroutineScope Result.Failure(errors) + suspend operator fun invoke(upsert: UpsertPassword): Result> { + val errors = validate(upsert) + if (errors.isNotEmpty()) return Result.Failure(errors) + + val updatedPassword = when (upsert.upsertType) { + is UpsertType.Create -> buildCreate(upsert, upsert.upsertType.vaultId) + is UpsertType.Update -> buildUpdate( + upsert = upsert, + id = upsert.upsertType.id, + targetVaultId = upsert.upsertType.targetVaultId, + ) + } + return when (updatedPassword) { + is Result.Success -> upsertVaultItem(updatedPassword.success).mapFailure { + setOf(PasswordError.DatabaseError(it)) + } + + is Result.Failure -> Result.Failure(setOf(updatedPassword.error)) + } + } - val encryptedPassword = upsert.password.onSet { password -> - async { - cryptographicScopeProvider.scope { - password.encryptSecretData() - } + private suspend fun buildCreate( + upsert: UpsertPassword, + vaultId: VaultId + ): Result { + val itemId = newItemId() + + val vaultKeyInformation = vaultRepository.getKeyInformation(vaultId) + ?: return Result.Failure(PasswordError.InvalidVaultId) + val aad = ItemAad(itemId = itemId, vaultId = vaultId) + + val password = cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = vaultKeyInformation, + vaultId = vaultId + ), + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + coroutineScope { + val encryptedPassword = async { + upsert.password.getValue()!!.encryptSecretData(label = LABEL_PASSWORD) + } + val encryptedTotp = upsert.totpSecret.onSet { secret -> + async { secret.encryptSecretData(label = LABEL_TOTP_SECRET) } + } + val passwordStrength = async { + passwordStrengthEstimator(upsert.password.getValue()!!) } - } - val passwordStrength = upsert.password.onSet { password -> - async { passwordStrengthEstimator(password) } + val wrappedItemKey = async { wrapCurrentItemKey() } + + Password( + id = itemId, + name = upsert.name.getValue()!!, + username = upsert.username.getValue(), + domainInfos = upsert.domains.getValue().orEmpty(), + password = encryptedPassword.await(), + totpSecret = encryptedTotp?.await(), + score = passwordStrength.await(), + note = upsert.note.getValue(), + pinned = false, + keyInformation = wrappedItemKey.await(), + vaultId = vaultId, + ) } + } + return Result.Success(password) + } - val totpSecret = upsert.totpSecret.onSet { totpSecret -> - async { - cryptographicScopeProvider.scope { - totpSecret.encryptSecretData() - } + private suspend fun buildUpdate( + upsert: UpsertPassword, + id: ItemId, + targetVaultId: VaultId?, + ): Result { + val existing = passwordRepository.getPasswordById(id) + ?: return Result.Failure(PasswordError.InvalidItemId) + + val sourceVaultKeyInfo = vaultRepository.getKeyInformation(existing.vaultId) + ?: return Result.Failure(PasswordError.InvalidVaultId) + val sourceVault = WrappedVaultKeyInformation( + wrappedVaultKey = sourceVaultKeyInfo, + vaultId = existing.vaultId, + ) + + val password = cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = sourceVault, + wrappedItemKeyInformation = existing.wrappedItemKeyInformation(), + ) { + coroutineScope { + val encryptedPassword = upsert.password.onSet { password -> + async { password.encryptSecretData(label = LABEL_PASSWORD) } } - } - - val updatedPassword = when (upsert.upsertType) { - UpsertType.Create -> { - // Validation ensures that the values are not null - Password( - name = upsert.name.getValue() ?: "", - username = upsert.username.getValue(), - domainInfos = upsert.domains.getValue().orEmpty(), - encryptedData = encryptedPassword!!.await(), - totpSecret = totpSecret?.await(), - score = passwordStrength!!.await(), - note = upsert.note.getValue(), - pinned = false, - ) + val totpSecret = upsert.totpSecret.onSet { secret -> + async { secret.encryptSecretData(label = LABEL_TOTP_SECRET) } } - - is UpsertType.Update -> { - val dbPassword = - passwordRepository.getPasswordById(upsert.upsertType.vaultItemId) - ?: return@coroutineScope Result.Failure(setOf(PasswordError.InvalidVaultId)) - - dbPassword.copy( - name = upsert.name.withoutClearingOn(dbPassword.name), - username = upsert.username.on(dbPassword.username), - domainInfos = upsert.domains.on(dbPassword.domainInfos).orEmpty(), - encryptedData = encryptedPassword?.await() ?: dbPassword.encryptedData, - totpSecret = upsert.totpSecret.on(dbPassword.totpSecret, totpSecret), - score = passwordStrength?.await() ?: dbPassword.score, - note = upsert.note.on(dbPassword.note), - ) + val passwordStrength = upsert.password.onSet { password -> + async { passwordStrengthEstimator(password) } } - } - upsertVaultItem(updatedPassword).mapFailure { - setOf(PasswordError.DatabaseError(it)) + existing.copy( + name = upsert.name.withoutClearingOn(existing.name), + username = upsert.username.on(existing.username), + domainInfos = upsert.domains.on(existing.domainInfos).orEmpty(), + password = encryptedPassword?.await() ?: existing.password, + totpSecret = upsert.totpSecret.on(existing.totpSecret, totpSecret), + score = passwordStrength?.await() ?: existing.score, + note = upsert.note.on(existing.note), + ) } } + + if (targetVaultId == null || targetVaultId == existing.vaultId) + return Result.Success(password) + + // Vault changed during edit: rewrap the item key under the destination vault. Encrypted + // secrets are bound only to the item id (see CryptographicScopeImpl.buildDataAad), so + // they remain valid under the same item key — no re-encryption needed. + val destinationVaultKeyInfo = vaultRepository.getKeyInformation(targetVaultId) + ?: return Result.Failure(PasswordError.InvalidVaultId) + + return when ( + val rewrapped = cryptographicScopeProvider.rewrapItemKey( + sourceVault = sourceVault, + sourceItem = existing.wrappedItemKeyInformation(), + destinationVault = WrappedVaultKeyInformation( + wrappedVaultKey = destinationVaultKeyInfo, + vaultId = targetVaultId, + ), + ) + ) { + is Result.Success -> Result.Success( + password.copy( + vaultId = targetVaultId, + keyInformation = rewrapped.success, + ) + ) + + is Result.Failure -> Result.Failure(PasswordError.DatabaseError(rewrapped.error)) + } + } } diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/DetailPaneInformation.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/DetailPaneInformation.kt index 4e99d228..e882ec07 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/DetailPaneInformation.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/model/DetailPaneInformation.kt @@ -9,7 +9,7 @@ sealed interface DetailPaneInformation { val itemType: VaultItemType data class New(override val itemType: VaultItemType) : Init - data class Existing(override val itemType: VaultItemType, val vaultItemId: ItemId) : Init + data class Existing(override val itemType: VaultItemType, val id: ItemId) : Init data class TOTP(override val itemType: VaultItemType, val uri: String) : Init } diff --git a/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCaseTest.kt b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCaseTest.kt new file mode 100644 index 00000000..f4dd7b4d --- /dev/null +++ b/feature/item/core/src/test/kotlin/de/davis/keygo/feature/item/core/domain/usecase/CreateNewOrUpdatePasswordUseCaseTest.kt @@ -0,0 +1,521 @@ +package de.davis.keygo.feature.item.core.domain.usecase + +import de.davis.keygo.core.item.FakePasswordRepository +import de.davis.keygo.core.item.FakePasswordStrengthEstimator +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.model.SecretData +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.usecase.UpsertVaultItemUseCase +import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.isSuccess +import de.davis.keygo.feature.item.core.domain.model.PasswordError +import de.davis.keygo.feature.item.core.domain.model.UpsertPassword +import de.davis.keygo.feature.item.core.domain.model.clear +import de.davis.keygo.feature.item.core.domain.model.set +import kotlinx.coroutines.test.runTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class CreateNewOrUpdatePasswordUseCaseTest { + + private val defaultVault = Vault( + id = newVaultId(), + name = "Default vault", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + icon = Vault.Icon.Default, + ) + + private val cryptoProvider = FakeCryptographicScopeProvider() + private val vaultRepository = FakeVaultRepository() + private val passwordRepository = FakePasswordRepository() + private val useCase = makeUseCase(passwordRepository, vaultRepository) + + @BeforeTest + fun setupVault() = runTest { + vaultRepository.seed(defaultVault) + } + + + // Validation — Create + + @Test + fun `create with blank name returns BlankName error`() = runTest { + val result = useCase( + UpsertPassword.create(vaultId = defaultVault.id, name = "", password = "secret") + ) + + assertTrue(result.isFailure()) + assertEquals(PasswordError.BlankName, result.error.single()) + } + + @Test + fun `create with whitespace-only name returns BlankName error`() = runTest { + val result = useCase( + UpsertPassword.create(vaultId = defaultVault.id, name = " ", password = "secret") + ) + + assertTrue(result.isFailure()) + assertEquals(PasswordError.BlankName, result.error.single()) + } + + @Test + fun `create with blank password returns BlankPassword error`() = runTest { + val result = useCase( + UpsertPassword.create(vaultId = defaultVault.id, name = "My site", password = "") + ) + + assertTrue(result.isFailure()) + assertEquals(PasswordError.BlankPassword, result.error.single()) + } + + @Test + fun `create with blank name and blank password returns both errors`() = runTest { + val result = useCase( + UpsertPassword.create(vaultId = defaultVault.id, name = "", password = "") + ) + + assertTrue(result.isFailure()) + assertContains(result.error, PasswordError.BlankName) + assertContains(result.error, PasswordError.BlankPassword) + assertEquals(2, result.error.size) + } + + // Validation — Update + + @Test + fun `update with Keep name and Keep password is valid`() = runTest { + val existing = testPassword() + passwordRepository.seed(existing) + + val result = useCase(UpsertPassword.update(itemId = existing.id)) + + assertTrue(result.isSuccess()) + } + + @Test + fun `update with Clear name returns BlankName error`() = runTest { + val existing = testPassword() + passwordRepository.seed(existing) + + val result = useCase(UpsertPassword.update(itemId = existing.id, name = clear())) + + assertTrue(result.isFailure()) + assertEquals(PasswordError.BlankName, result.error.single()) + } + + @Test + fun `update with blank Set name returns BlankName error`() = runTest { + val existing = testPassword() + passwordRepository.seed(existing) + + val result = useCase(UpsertPassword.update(itemId = existing.id, name = set(""))) + + assertTrue(result.isFailure()) + assertEquals(PasswordError.BlankName, result.error.single()) + } + + @Test + fun `update with Clear password returns BlankPassword error`() = runTest { + val existing = testPassword() + passwordRepository.seed(existing) + + val result = useCase(UpsertPassword.update(itemId = existing.id, password = clear())) + + assertTrue(result.isFailure()) + assertEquals(PasswordError.BlankPassword, result.error.single()) + } + + // Success — Create + + @Test + fun `create with valid fields returns Success`() = runTest { + val result = useCase( + UpsertPassword.create(vaultId = defaultVault.id, name = "My site", password = "s3cr3t") + ) + + assertTrue(result.isSuccess()) + } + + @Test + fun `create stores password with correct name`() = runTest { + val result = useCase( + UpsertPassword.create(vaultId = defaultVault.id, name = "My site", password = "s3cr3t") + ) + + val stored = storedById(result.getOrNull()) + assertNotNull(stored) + assertEquals("My site", stored.name) + } + + @Test + fun `create with optional username and note stores them`() = runTest { + val result = useCase( + UpsertPassword.create( + vaultId = defaultVault.id, + name = "My site", + password = "s3cr3t", + username = "user@example.com", + note = "A note", + ) + ) + + val stored = storedById(result.getOrNull()) + assertEquals("user@example.com", stored?.username) + assertEquals("A note", stored?.note) + } + + @Test + fun `create without optional fields stores nulls`() = runTest { + val result = useCase( + UpsertPassword.create(vaultId = defaultVault.id, name = "My site", password = "s3cr3t") + ) + + val stored = storedById(result.getOrNull()) + assertEquals(null, stored?.username) + assertEquals(null, stored?.note) + assertEquals(null, stored?.totpSecret) + } + + @Test + fun `create stores password strength from estimator`() = runTest { + val freshPasswordRepo = FakePasswordRepository() + val freshVaultRepo = FakeVaultRepository() + freshVaultRepo.seed(defaultVault) + + val localUseCase = makeUseCase( + passwordRepository = freshPasswordRepo, + vaultRepository = freshVaultRepo, + estimator = FakePasswordStrengthEstimator(Password.Score.Weak), + ) + + val result = localUseCase( + UpsertPassword.create(vaultId = defaultVault.id, name = "My site", password = "123") + ) + + val stored = freshPasswordRepo.getPasswordById(result.getOrNull()!!) + assertEquals(Password.Score.Weak, stored?.score) + } + + @Test + fun `update with new password re-evaluates password strength`() = runTest { + val existing = testPassword(score = Password.Score.Weak) + passwordRepository.seed(existing) + + // inject an estimator that returns Strong + val localUseCase = makeUseCase( + estimator = FakePasswordStrengthEstimator(Password.Score.Strong) + ) + + localUseCase(UpsertPassword.update(itemId = existing.id, password = set("SuperS3cr3t!"))) + + val updated = passwordRepository.getPasswordById(existing.id) + assertEquals(Password.Score.Strong, updated?.score) + } + + // Success — Update + + @Test + fun `update with new name replaces name`() = runTest { + val existing = testPassword(name = "Old name") + passwordRepository.seed(existing) + + useCase(UpsertPassword.update(itemId = existing.id, name = set("New name"))) + + assertEquals("New name", passwordRepository.getPasswordById(existing.id)?.name) + } + + @Test + fun `update with Keep name preserves existing name`() = runTest { + val existing = testPassword(name = "Preserved") + passwordRepository.seed(existing) + + useCase(UpsertPassword.update(itemId = existing.id)) + + assertEquals("Preserved", passwordRepository.getPasswordById(existing.id)?.name) + } + + @Test + fun `update with new username replaces username`() = runTest { + val existing = testPassword(username = "old@example.com") + passwordRepository.seed(existing) + + useCase(UpsertPassword.update(itemId = existing.id, username = set("new@example.com"))) + + assertEquals("new@example.com", passwordRepository.getPasswordById(existing.id)?.username) + } + + @Test + fun `update with Clear username sets username to null`() = runTest { + val existing = testPassword(username = "user@example.com") + passwordRepository.seed(existing) + + val result = useCase(UpsertPassword.update(itemId = existing.id, username = clear())) + + assertTrue(result.isSuccess(), "result: $result") + assertEquals(null, passwordRepository.getPasswordById(existing.id)?.username) + } + + // Failure + + @Test + fun `update with unknown id returns InvalidItemId error`() = runTest { + val result = useCase(UpsertPassword.update(itemId = newItemId())) + + assertTrue(result.isFailure()) + assertContains(result.error, PasswordError.InvalidItemId) + } + + // Vault move on update + + @Test + fun `update with different vaultId moves item to that vault`() = runTest { + val otherVault = defaultVault.copy(id = newVaultId(), name = "Other vault") + vaultRepository.seed(otherVault) + + val existing = testPassword() + passwordRepository.seed(existing) + + val result = useCase( + UpsertPassword.update(itemId = existing.id, vaultId = otherVault.id) + ) + + assertTrue(result.isSuccess(), "result: $result") + assertEquals(otherVault.id, passwordRepository.getPasswordById(existing.id)?.vaultId) + } + + @Test + fun `update with same vaultId keeps item in original vault`() = runTest { + val existing = testPassword() + passwordRepository.seed(existing) + + useCase(UpsertPassword.update(itemId = existing.id, vaultId = defaultVault.id)) + + assertEquals(defaultVault.id, passwordRepository.getPasswordById(existing.id)?.vaultId) + } + + @Test + fun `update with null vaultId keeps item in original vault`() = runTest { + val existing = testPassword() + passwordRepository.seed(existing) + + useCase(UpsertPassword.update(itemId = existing.id, name = set("Renamed"))) + + assertEquals(defaultVault.id, passwordRepository.getPasswordById(existing.id)?.vaultId) + } + + @Test + fun `update with unknown target vaultId returns InvalidVaultId error`() = runTest { + val existing = testPassword() + passwordRepository.seed(existing) + + val result = useCase( + UpsertPassword.update(itemId = existing.id, vaultId = newVaultId()) + ) + + assertTrue(result.isFailure()) + assertContains(result.error, PasswordError.InvalidVaultId) + } + + @Test + fun `update moving vaults also applies field changes`() = runTest { + val otherVault = defaultVault.copy(id = newVaultId(), name = "Other vault") + vaultRepository.seed(otherVault) + + val existing = testPassword(name = "Old name") + passwordRepository.seed(existing) + + useCase( + UpsertPassword.update( + itemId = existing.id, + vaultId = otherVault.id, + name = set("New name"), + ) + ) + + val stored = passwordRepository.getPasswordById(existing.id) + assertEquals(otherVault.id, stored?.vaultId) + assertEquals("New name", stored?.name) + } + + @Test + fun `update with different vaultId rewraps item key under the destination vault`() = runTest { + val otherVault = Vault( + id = newVaultId(), + name = "Other vault", + keyInformation = KeyInformation( + wrappedKey = byteArrayOf(0x0A), + keyNonce = byteArrayOf(0x0B), + ), + icon = Vault.Icon.Default, + ) + vaultRepository.seed(otherVault) + + val existingItemKey = KeyInformation( + wrappedKey = byteArrayOf(0x11, 0x22), + keyNonce = byteArrayOf(0x33, 0x44), + ) + val existing = testPassword().copy(keyInformation = existingItemKey) + passwordRepository.seed(existing) + + val rewrappedItemKey = KeyInformation( + wrappedKey = byteArrayOf(0xAA.toByte(), 0xBB.toByte()), + keyNonce = byteArrayOf(0xCC.toByte(), 0xDD.toByte()), + ) + cryptoProvider.rewrapResult = Result.Success(rewrappedItemKey) + + val result = useCase( + UpsertPassword.update(itemId = existing.id, vaultId = otherVault.id) + ) + + assertTrue(result.isSuccess(), "result: $result") + + val rewrap = cryptoProvider.rewrapCalls.single() + assertEquals(defaultVault.id, rewrap.sourceVault.vaultId) + assertContentEquals( + defaultVault.keyInformation.wrappedKey, + rewrap.sourceVault.wrappedVaultKey.wrappedKey, + ) + assertEquals(otherVault.id, rewrap.destinationVault.vaultId) + assertContentEquals( + otherVault.keyInformation.wrappedKey, + rewrap.destinationVault.wrappedVaultKey.wrappedKey, + ) + assertEquals(existing.id, rewrap.sourceItem.itemAad.itemId) + assertEquals(defaultVault.id, rewrap.sourceItem.itemAad.vaultId) + assertContentEquals( + existingItemKey.wrappedKey, + rewrap.sourceItem.wrappedItemKey?.wrappedKey, + ) + + val stored = passwordRepository.getPasswordById(existing.id) + assertNotNull(stored) + assertEquals(otherVault.id, stored.vaultId) + assertContentEquals(rewrappedItemKey.wrappedKey, stored.keyInformation.wrappedKey) + assertContentEquals(rewrappedItemKey.keyNonce, stored.keyInformation.keyNonce) + } + + @Test + fun `update with same vaultId does not rewrap`() = runTest { + val existing = testPassword() + passwordRepository.seed(existing) + + useCase(UpsertPassword.update(itemId = existing.id, vaultId = defaultVault.id)) + + assertTrue(cryptoProvider.rewrapCalls.isEmpty()) + } + + // Crypto scope + + @Test + fun `create routes secret fields through the crypto scope`() = runTest { + val plaintextPassword = "s3cr3t" + val plaintextTotp = "JBSWY3DPEHPK3PXP" + + val result = useCase( + UpsertPassword.create( + vaultId = defaultVault.id, + name = "My site", + password = plaintextPassword, + totpSecret = plaintextTotp, + ) + ) + + val stored = storedById(result.getOrNull()) + assertNotNull(stored) + + val labels = cryptoProvider.encryptCalls.map { it.label } + assertContains(labels, Password.LABEL_PASSWORD) + assertContains(labels, Password.LABEL_TOTP_SECRET) + assertEquals(2, labels.size) + + val passwordCall = + cryptoProvider.encryptCalls.single { it.label == Password.LABEL_PASSWORD } + assertContentEquals(plaintextPassword.encodeToByteArray(), passwordCall.plaintext) + + val totpCall = + cryptoProvider.encryptCalls.single { it.label == Password.LABEL_TOTP_SECRET } + assertContentEquals(plaintextTotp.encodeToByteArray(), totpCall.plaintext) + + assertFalse(stored.password.data.contentEquals(plaintextPassword.encodeToByteArray())) + assertContentEquals( + FakeCryptographicScopeProvider.transform(plaintextPassword.encodeToByteArray()), + stored.password.data + ) + assertContentEquals(FakeCryptographicScopeProvider.IV, stored.password.iv) + + assertNotNull(stored.totpSecret) + assertFalse(stored.totpSecret!!.data.contentEquals(plaintextTotp.encodeToByteArray())) + assertContentEquals( + FakeCryptographicScopeProvider.transform(plaintextTotp.encodeToByteArray()), + stored.totpSecret!!.data + ) + } + + @Test + fun `repository failure on create wraps error in DatabaseError`() = runTest { + val cause = RuntimeException("disk full") + passwordRepository.createOrUpdateError = cause + + val result = useCase( + UpsertPassword.create(vaultId = defaultVault.id, name = "My site", password = "s3cr3t") + ) + + assertTrue(result.isFailure()) + val error = assertIs(result.error.single()) + assertEquals(cause, error.throwable) + } + + // Helpers + + private fun makeUseCase( + passwordRepository: FakePasswordRepository = this@CreateNewOrUpdatePasswordUseCaseTest.passwordRepository, + vaultRepository: FakeVaultRepository = this@CreateNewOrUpdatePasswordUseCaseTest.vaultRepository, + estimator: FakePasswordStrengthEstimator = FakePasswordStrengthEstimator(), + cryptographicScopeProvider: CryptographicScopeProvider = cryptoProvider, + ) = CreateNewOrUpdatePasswordUseCase( + cryptographicScopeProvider = cryptographicScopeProvider, + passwordRepository = passwordRepository, + vaultRepository = vaultRepository, + upsertVaultItem = UpsertVaultItemUseCase(passwordRepository), + passwordStrengthEstimator = estimator, + ) + + private fun testPassword( + name: String = "Test", + username: String? = null, + score: Password.Score = Password.Score.Strong, + ) = Password( + id = newItemId(), + name = name, + username = username, + domainInfos = emptySet(), + score = score, + password = SecretData.EMPTY_STRING, + totpSecret = null, + note = null, + pinned = false, + vaultId = defaultVault.id, + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + ) + + private suspend fun storedById(id: ItemId?): Password? { + id ?: return null + return passwordRepository.getPasswordById(id) + } +} diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/KeyGoItemForm.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/KeyGoItemForm.kt index e37bacb6..a5baa437 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/KeyGoItemForm.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/KeyGoItemForm.kt @@ -4,7 +4,10 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -14,6 +17,8 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.WarningAmber +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -26,18 +31,26 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultMetadata import de.davis.keygo.core.ui.components.KeyGoCard import de.davis.keygo.core.ui.components.KeyGoCardProperties import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField import de.davis.keygo.feature.item.core.presentation.model.InputFieldError import de.davis.keygo.feature.item.create.R +import de.davis.keygo.feature.item.create.presentation.model.VaultsState import de.davis.keygo.feature.item.core.R as ItemCoreR +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun KeyGoItemForm( nameTextFieldState: TextFieldState, notesTextFieldState: TextFieldState, + vaultsState: VaultsState?, + onVaultSelect: (VaultId) -> Unit, modifier: Modifier = Modifier, nameError: InputFieldError? = null, nameExists: Boolean = false, @@ -83,6 +96,16 @@ fun KeyGoItemForm( placeholder = { Text(text = stringResource(R.string.name_placeholder)) }, error = nameError ) + + // We only show the vault selection when there are more then one vaults available + vaultsState?.takeIf { it.vaults.size > 1 }?.let { state -> + Spacer(modifier = Modifier.height(8.dp)) + VaultDropDownMenu( + vaultsState = state, + onVaultSelect = onVaultSelect, + modifier = Modifier.fillMaxWidth() + ) + } } } } @@ -137,6 +160,24 @@ private fun KeyGoItemFormPreview() { KeyGoItemForm( nameTextFieldState = remember { TextFieldState() }, notesTextFieldState = remember { TextFieldState() }, + vaultsState = remember { + val id = newVaultId() + VaultsState( + vaults = listOf( + VaultMetadata( + vaultId = newVaultId(), + name = "Vault 1", + icon = Vault.Icon.Default + ), + VaultMetadata( + vaultId = id, + name = "Vault 2", + icon = Vault.Icon.Work + ) + ), selectedVaultId = id + ) + }, + onVaultSelect = {}, nameExists = true ) { item(key = "password_information") { diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/SelectItemForTotpModificationDialog.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/SelectItemForTotpModificationDialog.kt index fe07ce97..db1cf611 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/SelectItemForTotpModificationDialog.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/SelectItemForTotpModificationDialog.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.lite.LitePassword import de.davis.keygo.core.ui.theme.KeyGoTheme @@ -67,7 +68,7 @@ fun SelectItemForTotpModificationDialog( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(items = items, key = { it.vaultItemId }) { item -> + items(items = items, key = { it.id }) { item -> OutlinedCard( onClick = { onItemClicked(item) @@ -127,19 +128,29 @@ private fun SelectItemForTotpModificationDialogPreview() { onCreateNew = {}, items = listOf( LitePassword( - vaultItemId = 1, - passwordId = 1, + id = newItemId(), name = "${if (1 >= 5) 'A' else 'B'} Item 1", username = "User 1", - domains = listOf(DomainInfo(1, "Website", "website.com")), + domains = listOf( + DomainInfo( + passwordId = newItemId(), + value = "Website", + eTLD1 = "website.com" + ) + ), pinned = false, ), LitePassword( - vaultItemId = 2, - passwordId = 2, + id = newItemId(), name = "${if (2 >= 5) 'A' else 'B'} Item 2", username = "User 2", - domains = listOf(DomainInfo(12, "Website", "website.com")), + domains = listOf( + DomainInfo( + passwordId = newItemId(), + value = "Website", + eTLD1 = "website.com" + ) + ), pinned = false, ) ) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/VaultDropDownMenu.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/VaultDropDownMenu.kt new file mode 100644 index 00000000..93fd097e --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/component/VaultDropDownMenu.kt @@ -0,0 +1,109 @@ +package de.davis.keygo.feature.item.create.presentation.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.presentation.toImageVector +import de.davis.keygo.feature.item.create.R +import de.davis.keygo.feature.item.create.presentation.model.VaultsState + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun VaultDropDownMenu( + vaultsState: VaultsState, + onVaultSelect: (VaultId) -> Unit, + modifier: Modifier = Modifier +) { + var expanded by remember { mutableStateOf(false) } + + val selectedVault = remember(vaultsState) { + vaultsState.vaults.first { it.vaultId == vaultsState.selectedVaultId } + } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = selectedVault.name, + onValueChange = {}, + readOnly = true, + singleLine = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + leadingIcon = { + Icon( + imageVector = selectedVault.icon.toImageVector(), + modifier = Modifier.size(MenuDefaults.LeadingIconSize), + contentDescription = null, + ) + }, + label = { Text(text = stringResource(R.string.vault)) }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + val optionCount = vaultsState.vaults.size + vaultsState.vaults.forEachIndexed { index, metadata -> + DropdownMenuItem( + shapes = MenuDefaults.itemShape(index, optionCount), + colors = MenuDefaults.selectableItemColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + text = { + Text( + metadata.name, + style = MaterialTheme.typography.bodyLarge + ) + }, + selected = metadata.vaultId == vaultsState.selectedVaultId, + onClick = { + onVaultSelect(metadata.vaultId) + expanded = false + }, + selectedLeadingIcon = { + Icon( + imageVector = Icons.Filled.Check, + modifier = Modifier.size(MenuDefaults.LeadingIconSize), + contentDescription = null, + ) + }, + leadingIcon = { + Icon( + imageVector = metadata.icon.toImageVector(), + modifier = Modifier.size(MenuDefaults.LeadingIconSize), + contentDescription = null, + ) + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} \ No newline at end of file diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/VaultsState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/VaultsState.kt new file mode 100644 index 00000000..0b02d125 --- /dev/null +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/model/VaultsState.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.item.create.presentation.model + +import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.VaultMetadata + +@Stable +data class VaultsState( + val vaults: List, + val selectedVaultId: VaultId, +) diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/PasswordContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/PasswordContent.kt index 09db574d..64ba00f5 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/PasswordContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/PasswordContent.kt @@ -2,7 +2,9 @@ package de.davis.keygo.feature.item.create.presentation.password import android.content.res.Configuration import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -15,6 +17,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -23,11 +26,13 @@ import androidx.compose.material3.MediumFlexibleTopAppBar import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -35,8 +40,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultMetadata import de.davis.keygo.core.item.presentation.StrengthIndicator import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode import de.davis.keygo.core.ui.theme.KeyGoTheme @@ -49,7 +58,9 @@ import de.davis.keygo.feature.item.create.presentation.component.KeyGoItemForm import de.davis.keygo.feature.item.create.presentation.component.OverrideTotpDialog import de.davis.keygo.feature.item.create.presentation.component.SelectItemForTotpModificationDialog import de.davis.keygo.feature.item.create.presentation.component.TotpParseErrorDialog +import de.davis.keygo.feature.item.create.presentation.model.VaultsState import de.davis.keygo.feature.item.create.presentation.password.model.DialogState +import de.davis.keygo.feature.item.create.presentation.password.model.PasswordBaseState import de.davis.keygo.feature.item.create.presentation.password.model.PasswordUiEvent import de.davis.keygo.feature.item.create.presentation.password.model.PasswordUiState import de.davis.keygo.feature.totp.presentation.component.QRScanner @@ -57,9 +68,51 @@ import de.davis.keygo.core.item.R as CoreItemR import de.davis.keygo.core.ui.R as CoreUiR import de.davis.keygo.feature.item.core.R as ItemCoreR -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable internal fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) -> Unit) { + when (state) { + PasswordUiState.Loading -> PasswordLoadingScaffold( + onBackClick = { onEvent(PasswordUiEvent.OnBackClick) }, + ) + + is PasswordUiState.Ready -> PasswordReadyContent( + state = state.base, + vaultsState = state.vaultsState, + onEvent = onEvent, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PasswordLoadingScaffold(onBackClick: () -> Unit) { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + PasswordTopAppBar( + updating = false, + onBackClick = onBackClick, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + ContainedLoadingIndicator() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PasswordReadyContent( + state: PasswordBaseState, + vaultsState: VaultsState, + onEvent: (PasswordUiEvent) -> Unit, +) { val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val domainTextFieldState = rememberTextFieldState() val schemeTransformation = rememberSchemeStrippingTransformation() @@ -67,30 +120,9 @@ internal fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - MediumFlexibleTopAppBar( - title = { - Text( - text = stringResource( - when { - state.updating -> R.string.update_item - else -> CoreUiR.string.create_new_item - } - ) - ) - }, - subtitle = { - Text(text = stringResource(CoreItemR.string.password)) - }, - navigationIcon = { - if (LocalIsInSinglePaneMode.current) { - IconButton(onClick = { onEvent(PasswordUiEvent.OnBackClick) }) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = stringResource(ItemCoreR.string.back_content_description) - ) - } - } - }, + PasswordTopAppBar( + updating = state.updating, + onBackClick = { onEvent(PasswordUiEvent.OnBackClick) }, actions = { IconButton(onClick = { val pending = domainTextFieldState.text.toString() @@ -108,7 +140,7 @@ internal fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) ) } }, - scrollBehavior = scrollBehavior + scrollBehavior = scrollBehavior, ) } ) { innerPadding -> @@ -123,6 +155,8 @@ internal fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) .nestedScroll(scrollBehavior.nestedScrollConnection), nameError = state.nameError, nameExists = state.nameExists, + vaultsState = vaultsState, + onVaultSelect = { onEvent(PasswordUiEvent.OnVaultSelected(it)) } ) { item(key = "password_information") { var forceCompact by rememberSaveable { mutableStateOf(false) } @@ -240,7 +274,7 @@ internal fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) }, items = state.dialogState.items, onItemClicked = { item -> - onEvent(PasswordUiEvent.OnTotpModificationItemSelected(item.vaultItemId)) + onEvent(PasswordUiEvent.OnTotpModificationItemSelected(item.id)) }, onCreateNew = { onEvent(PasswordUiEvent.OnCreateNewItemForTotp) }, modifier = Modifier.fillMaxWidth() @@ -285,6 +319,42 @@ internal fun PasswordContent(state: PasswordUiState, onEvent: (PasswordUiEvent) } } +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun PasswordTopAppBar( + updating: Boolean, + onBackClick: () -> Unit, + actions: @Composable RowScope.() -> Unit = {}, + scrollBehavior: TopAppBarScrollBehavior? = null +) { + MediumFlexibleTopAppBar( + title = { + Text( + text = stringResource( + when { + updating -> R.string.update_item + else -> CoreUiR.string.create_new_item + } + ) + ) + }, + subtitle = { + Text(text = stringResource(CoreItemR.string.password)) + }, + navigationIcon = { + if (LocalIsInSinglePaneMode.current) { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = stringResource(ItemCoreR.string.back_content_description) + ) + } + } + }, + actions = actions, + scrollBehavior = scrollBehavior + ) +} private val DELIMITERS = setOf(',', ' ') @@ -292,20 +362,33 @@ private val DELIMITERS = setOf(',', ' ') @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL) @Composable private fun PasswordContentPreview() { + val selectedVaultId = newVaultId() KeyGoTheme { PasswordContent( - state = PasswordUiState( - strengthScore = Password.Score.Weak, - domains = setOf( - DomainInfo( - passwordId = 0, - value = "example.com", - eTLD1 = "example.com" - ) + state = PasswordUiState.Ready( + base = PasswordBaseState( + strengthScore = Password.Score.Weak, + domains = setOf( + DomainInfo( + passwordId = newItemId(), + value = "example.com", + eTLD1 = "example.com", + ), + ), + nameExists = true, + ), + vaultsState = VaultsState( + vaults = listOf( + VaultMetadata( + vaultId = selectedVaultId, + name = "Vault 1", + icon = Vault.Icon.Default, + ), + ), + selectedVaultId = selectedVaultId, ), - nameExists = true ), - onEvent = {} + onEvent = {}, ) } } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/PasswordViewModel.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/PasswordViewModel.kt index 91a0105c..994e6acd 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/PasswordViewModel.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/PasswordViewModel.kt @@ -7,13 +7,16 @@ import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.alias.ItemIdNone -import de.davis.keygo.core.item.domain.crypto.decryptSecretData +import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.estimator.PasswordStrengthEstimator import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.repository.ItemRepository import de.davis.keygo.core.item.domain.repository.PasswordRepository -import de.davis.keygo.core.item.domain.repository.VaultItemRepository -import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.security.domain.crypto.decryptSecretData +import de.davis.keygo.core.security.domain.usecase.PasswordWithCryptoScopeUseCase import de.davis.keygo.core.util.domain.model.snackbar.SnackbarMessage import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver import de.davis.keygo.core.util.domain.snackbar.SnackbarManager @@ -30,8 +33,10 @@ import de.davis.keygo.feature.item.core.presentation.model.DetailPaneInformation import de.davis.keygo.feature.item.core.presentation.model.InputFieldError import de.davis.keygo.feature.item.core.presentation.password.model.FieldType import de.davis.keygo.feature.item.create.R +import de.davis.keygo.feature.item.create.presentation.model.VaultsState import de.davis.keygo.feature.item.create.presentation.password.model.DialogState import de.davis.keygo.feature.item.create.presentation.password.model.OverrideTotpField +import de.davis.keygo.feature.item.create.presentation.password.model.PasswordBaseState import de.davis.keygo.feature.item.create.presentation.password.model.PasswordUiEvent import de.davis.keygo.feature.item.create.presentation.password.model.PasswordUiState import de.davis.keygo.feature.totp.domain.model.TotpSecretInformation @@ -42,8 +47,10 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull @@ -61,52 +68,68 @@ import kotlin.time.Duration.Companion.milliseconds @KoinViewModel internal class PasswordViewModel( - private val vaultItemRepository: VaultItemRepository, + private val passwordWithCryptoScopeUseCase: PasswordWithCryptoScopeUseCase, + private val itemRepository: ItemRepository, private val passwordRepository: PasswordRepository, - private val cryptographicScopeProvider: CryptographicScopeProvider, + private val vaultContextRepository: VaultContextRepository, private val passwordStrengthEstimator: PasswordStrengthEstimator, private val createNewOrUpdatePassword: CreateNewOrUpdatePasswordUseCase, private val snackbarManager: SnackbarManager, private val getTotpSecret: GetTotpSecretFromUrlUseCase, - private val registrableDomainResolver: RegistrableDomainResolver + private val registrableDomainResolver: RegistrableDomainResolver, + vaultRepository: VaultRepository ) : ViewModel() { private val nameTextFieldState = TextFieldState() private val passwordTextFieldState = TextFieldState() - private val _uiState = MutableStateFlow( - PasswordUiState( + private val _base = MutableStateFlow( + PasswordBaseState( nameTextFieldState = nameTextFieldState, passwordTextFieldState = passwordTextFieldState ) ) - val state = _uiState - .onStart { - observeNameTextField() - observePasswordTextField() - } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = _uiState.value - ) + private val _selectedVaultId = MutableStateFlow(null) + + private val vaultsFlow: Flow = combine( + vaultRepository.observeAllVaultMetadata(), + _selectedVaultId.filterNotNull(), + ) { metadata, selected -> + VaultsState(vaults = metadata, selectedVaultId = selected) + }.distinctUntilChanged() + + val state = combine(_base, vaultsFlow) { base, vaults -> + PasswordUiState.Ready(base = base, vaultsState = vaults) + }.onStart { + observeNameTextField() + observePasswordTextField() + primeActiveVaultId() + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = PasswordUiState.Loading, + ) private val itemCreatedEventChannel = Channel() val itemCreatedEvent = itemCreatedEventChannel.receiveAsFlow() - private var itemId = ItemIdNone + private var itemId: ItemId? = null private var totpSecretInformation: TotpSecretInformation? = null @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) private fun observeNameTextField() { snapshotFlow { nameTextFieldState.text } .debounce(150.milliseconds) - .mapLatest { input -> - vaultItemRepository.doesNameExist(input.toString(), excludeId = itemId) + .combine(_selectedVaultId.filterNotNull()) { input, vaultId -> + itemRepository.doesNameExist( + input.toString(), + excludeId = itemId, + vaultId = vaultId + ) } .distinctUntilChanged() .onEach { exists -> - _uiState.update { + _base.update { it.copy(nameExists = exists) } } @@ -121,7 +144,7 @@ internal class PasswordViewModel( .mapLatest { passwordStrengthEstimator(it.toString()) } .distinctUntilChanged() .onEach { score -> - _uiState.update { + _base.update { it.copy(strengthScore = score) } } @@ -129,6 +152,13 @@ internal class PasswordViewModel( .launchIn(viewModelScope) } + private fun primeActiveVaultId() { + viewModelScope.launch { + val activeId = vaultContextRepository.getLastInteractedVaultId() ?: return@launch + _selectedVaultId.compareAndSet(null, activeId) + } + } + private fun navigateUp(itemId: ItemId? = null) { viewModelScope.launch { itemCreatedEventChannel.send(itemId) @@ -137,7 +167,7 @@ internal class PasswordViewModel( fun init(information: DetailPaneInformation) { when (information) { - is DetailPaneInformation.Init.Existing -> initWithId(information.vaultItemId) + is DetailPaneInformation.Init.Existing -> viewModelScope.launch { initWithId(information.id) } is DetailPaneInformation.Init.TOTP -> initWithTotpUri(information.uri) is DetailPaneInformation.Init.New -> {} // Don't init anything @@ -159,7 +189,7 @@ internal class PasswordViewModel( } passwordTextFieldState.setTextAndPlaceCursorAtEnd(createRaw.password) - _uiState.update { + _base.update { it.copy( usernameTextFieldState = TextFieldState(createRaw.username), domains = setOfNotNull(domainInfo), @@ -170,50 +200,40 @@ internal class PasswordViewModel( } } - private fun initWithId(itemId: ItemId) { + private suspend fun initWithId(itemId: ItemId) { this.itemId = itemId - if (itemId == ItemIdNone) return - - passwordRepository.observePasswordById(itemId) - .filterNotNull() - .onEach { password -> - coroutineScope { - val pwdDeferred = async { - cryptographicScopeProvider.scope { - password.encryptedData.decryptSecretData() - } - } - val totpSecret = password.totpSecret?.let { totpSecret -> - async { - cryptographicScopeProvider.scope { - totpSecret.decryptSecretData() - } - } - } + passwordWithCryptoScopeUseCase.oneShot( + itemId = itemId, + ) { password -> + val decrypted = coroutineScope { + val pwdDeferred = + async { password.password.decryptSecretData(label = Password.LABEL_PASSWORD) } + val totpDeferred = password.totpSecret?.let { totpSecret -> + async { totpSecret.decryptSecretData(label = Password.LABEL_TOTP_SECRET) } + } + pwdDeferred.await() to totpDeferred?.await() + } - nameTextFieldState.setTextAndPlaceCursorAtEnd(password.name) - passwordTextFieldState.setTextAndPlaceCursorAtEnd(pwdDeferred.await()) - - _uiState.update { - it.copy( - totpTextFieldState = TextFieldState(totpSecret?.await() ?: ""), - usernameTextFieldState = TextFieldState(password.username ?: ""), - domains = password.domainInfos, - notesTextFieldState = TextFieldState(password.note ?: ""), - dialogState = DialogState.None, - updating = true - ) - } + nameTextFieldState.setTextAndPlaceCursorAtEnd(password.name) + passwordTextFieldState.setTextAndPlaceCursorAtEnd(decrypted.first) - // Update the TOTP secret information if available - totpSecretInformation?.let { - requestTotpSecretUpdate(it) - } - } + _selectedVaultId.update { password.vaultId } + _base.update { + it.copy( + totpTextFieldState = TextFieldState(decrypted.second ?: ""), + usernameTextFieldState = TextFieldState(password.username ?: ""), + domains = password.domainInfos, + notesTextFieldState = TextFieldState(password.note ?: ""), + dialogState = DialogState.None, + updating = true, + ) } - .flowOn(Dispatchers.Default) - .launchIn(viewModelScope) + + totpSecretInformation?.let { + requestTotpSecretUpdate(it) + } + } } private fun initWithTotpUri(totpUri: String) { @@ -234,7 +254,7 @@ internal class PasswordViewModel( return@launch } - _uiState.update { + _base.update { it.copy( dialogState = DialogState.SelectItemForModification( items = matchedItems @@ -248,33 +268,36 @@ internal class PasswordViewModel( fun onEvent(event: PasswordUiEvent) { when (event) { is PasswordUiEvent.OnSubmit -> { + val ready = state.value as? PasswordUiState.Ready ?: return + val base = ready.base viewModelScope.launch { - val state = _uiState.value - createNewOrUpdatePassword( - upsert = when (itemId == ItemIdNone) { - true -> UpsertPassword.create( - name = state.nameTextFieldState.text.toString(), - username = state.usernameTextFieldState.text.toString(), - domains = state.domains, - password = state.passwordTextFieldState.text.toString(), - totpSecret = state.totpTextFieldState.text.toString(), - note = state.notesTextFieldState.text.toString() - ) + val upsert = itemId?.let { itemId -> + UpsertPassword.update( + itemId = itemId, + vaultId = ready.vaultsState.selectedVaultId, + name = fieldUpdate(base.nameTextFieldState.text.toString()), + username = fieldUpdate(base.usernameTextFieldState.text.toString()), + domains = set(base.domains), + password = fieldUpdate(base.passwordTextFieldState.text.toString()), + totpSecret = fieldUpdate(base.totpTextFieldState.text.toString()), + note = fieldUpdate(base.notesTextFieldState.text.toString()) + ) + } ?: UpsertPassword.create( + vaultId = ready.vaultsState.selectedVaultId, + name = base.nameTextFieldState.text.toString(), + username = base.usernameTextFieldState.text.toString(), + domains = base.domains, + password = base.passwordTextFieldState.text.toString(), + totpSecret = base.totpTextFieldState.text.toString(), + note = base.notesTextFieldState.text.toString() + ) - false -> UpsertPassword.update( - vaultId = itemId, - name = fieldUpdate(state.nameTextFieldState.text.toString()), - username = fieldUpdate(state.usernameTextFieldState.text.toString()), - domains = set(state.domains), - password = fieldUpdate(state.passwordTextFieldState.text.toString()), - totpSecret = fieldUpdate(state.totpTextFieldState.text.toString()), - note = fieldUpdate(state.notesTextFieldState.text.toString()) - ) - } + createNewOrUpdatePassword( + upsert = upsert ).onSuccess { navigateUp(it) }.onFailure { failure -> - _uiState.update { + _base.update { it.copy( nameError = if (failure.contains(PasswordError.BlankName)) InputFieldError.Empty else null, passwordError = if (failure.contains(PasswordError.BlankPassword)) InputFieldError.Empty else null @@ -308,12 +331,12 @@ internal class PasswordViewModel( } is PasswordUiEvent.OnGeneratePasswordClick -> { - _uiState.update { it.copy(generatePasswordBottomSheetVisible = true) } + _base.update { it.copy(generatePasswordBottomSheetVisible = true) } } is PasswordUiEvent.OnBackClick -> { - if (_uiState.value.scanning) { - _uiState.update { it.copy(scanning = false) } + if (_base.value.scanning) { + _base.update { it.copy(scanning = false) } return } @@ -321,11 +344,11 @@ internal class PasswordViewModel( } is PasswordUiEvent.OnCloseBottomSheet -> { - _uiState.update { it.copy(generatePasswordBottomSheetVisible = false) } + _base.update { it.copy(generatePasswordBottomSheetVisible = false) } } is PasswordUiEvent.OnScanCodeRequest -> { - _uiState.update { it.copy(scanning = true) } + _base.update { it.copy(scanning = true) } } is PasswordUiEvent.OnCodesScanned -> { @@ -334,7 +357,7 @@ internal class PasswordViewModel( Log.e(TAG, "Error parsing TOTP URI: $failure") }.getOrNull() }?.let { - _uiState.update { state -> + _base.update { state -> state.copy(scanning = false) } @@ -344,7 +367,7 @@ internal class PasswordViewModel( } is PasswordUiEvent.OnTotpModificationItemSelected -> { - initWithId(event.itemId) + viewModelScope.launch { initWithId(event.itemId) } } is PasswordUiEvent.OnCreateNewItemForTotp -> { @@ -354,7 +377,7 @@ internal class PasswordViewModel( } is PasswordUiEvent.OnOverrideFieldClicked -> { - _uiState.update { + _base.update { it.copy( dialogState = when (it.dialogState) { is DialogState.OverrideTotp -> { @@ -373,7 +396,7 @@ internal class PasswordViewModel( } is PasswordUiEvent.OnOverrideTotpFieldsConfirmed -> { - val currentDialogState = _uiState.value.dialogState + val currentDialogState = _base.value.dialogState if (currentDialogState !is DialogState.OverrideTotp) return totpSecretInformation?.let { @@ -385,7 +408,7 @@ internal class PasswordViewModel( } is PasswordUiEvent.OnOverrideTotpFieldsKept -> { - val currentDialogState = _uiState.value.dialogState + val currentDialogState = _base.value.dialogState if (currentDialogState !is DialogState.OverrideTotp) return totpSecretInformation?.let { @@ -394,7 +417,7 @@ internal class PasswordViewModel( } is PasswordUiEvent.OnTotpParseErrorDismiss -> { - _uiState.update { + _base.update { it.copy( dialogState = DialogState.None, scanning = false @@ -403,33 +426,39 @@ internal class PasswordViewModel( } is PasswordUiEvent.OnAddDomains -> { - event.domains.forEach { domain -> - val registrableDomain = registrableDomainResolver.resolve(domain) - val info = DomainInfo( - passwordId = itemId, - value = domain, - eTLD1 = registrableDomain - ) - _uiState.update { - val newList = it.domains + info - it.copy(domains = newList) + itemId?.let { itemId -> + event.domains.forEach { domain -> + val registrableDomain = registrableDomainResolver.resolve(domain) + val info = DomainInfo( + passwordId = itemId, + value = domain, + eTLD1 = registrableDomain + ) + _base.update { + it.copy(domains = it.domains + info) + } } } } is PasswordUiEvent.OnDeleteDomain -> { - _uiState.update { - val newList = it.domains.filterNot { info -> info.value == event.value }.toSet() - it.copy(domains = newList) + _base.update { + it.copy( + domains = it.domains.filterNot { info -> info.value == event.value }.toSet() + ) } } is PasswordUiEvent.OnPasswordGenerated -> { passwordTextFieldState.setTextAndPlaceCursorAtEnd(event.password) - _uiState.update { + _base.update { it.copy(generatePasswordBottomSheetVisible = false) } } + + is PasswordUiEvent.OnVaultSelected -> { + _selectedVaultId.value = event.vaultId + } } } @@ -447,7 +476,7 @@ internal class PasswordViewModel( } private fun requestTotpSecretUpdate(secretInformation: TotpSecretInformation) { - val currentState = _uiState.value + val currentState = _base.value val currentTotpSecret = currentState.totpTextFieldState.text.toString() val currentIssuers = currentState.domains val currentAccountName = currentState.usernameTextFieldState.text.toString() @@ -497,7 +526,7 @@ internal class PasswordViewModel( ) if (overridingFields.isNotEmpty()) - _uiState.update { + _base.update { it.copy(dialogState = DialogState.OverrideTotp(fields = overridingFields)) } } @@ -515,7 +544,7 @@ internal class PasswordViewModel( accountName: String? = null, closeDialog: Boolean = true ) { - val currentState = _uiState.value + val currentState = _base.value secret?.let { currentState.totpTextFieldState.setTextAndPlaceCursorAtEnd(it) } @@ -529,7 +558,7 @@ internal class PasswordViewModel( } if (closeDialog) - _uiState.update { + _base.update { it.copy( dialogState = DialogState.None, ) @@ -537,7 +566,7 @@ internal class PasswordViewModel( } private fun showTotpParseError() { - _uiState.update { + _base.update { it.copy( dialogState = DialogState.TotpParseError, scanning = false diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/PasswordUiEvent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/PasswordUiEvent.kt index 4a185c10..5dab27c5 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/PasswordUiEvent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/PasswordUiEvent.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.item.create.presentation.password.model import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.feature.item.core.presentation.password.model.FieldType internal sealed interface PasswordUiEvent { @@ -24,4 +25,6 @@ internal sealed interface PasswordUiEvent { data object OnOverrideTotpFieldsKept : PasswordUiEvent data class OnPasswordGenerated(val password: String) : PasswordUiEvent + + data class OnVaultSelected(val vaultId: VaultId) : PasswordUiEvent } \ No newline at end of file diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/PasswordUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/PasswordUiState.kt index 885d6391..1f617ff1 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/PasswordUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/password/model/PasswordUiState.kt @@ -1,13 +1,24 @@ package de.davis.keygo.feature.item.create.presentation.password.model import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.Password import de.davis.keygo.feature.item.core.presentation.model.InputFieldError +import de.davis.keygo.feature.item.create.presentation.model.VaultsState -@Immutable -internal data class PasswordUiState( +@Stable +internal sealed interface PasswordUiState { + data object Loading : PasswordUiState + + data class Ready( + val base: PasswordBaseState, + val vaultsState: VaultsState, + ) : PasswordUiState +} + +@Stable +internal data class PasswordBaseState( val nameTextFieldState: TextFieldState = TextFieldState(), val notesTextFieldState: TextFieldState = TextFieldState(), val passwordTextFieldState: TextFieldState = TextFieldState(), diff --git a/feature/item/create/src/main/res/values/strings.xml b/feature/item/create/src/main/res/values/strings.xml index 02febb93..cadc3ecc 100644 --- a/feature/item/create/src/main/res/values/strings.xml +++ b/feature/item/create/src/main/res/values/strings.xml @@ -60,4 +60,6 @@ An unexpected database error has occurred: %s Vault item ID can not be found + + Vault \ No newline at end of file diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/ViewPasswordContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/ViewPasswordContent.kt index 3313b68d..0165a56d 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/ViewPasswordContent.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/ViewPasswordContent.kt @@ -8,10 +8,12 @@ import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.rememberScrollState @@ -42,6 +44,8 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MediumFlexibleTopAppBar import androidx.compose.material3.Scaffold @@ -57,17 +61,21 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.Password import de.davis.keygo.core.item.presentation.StrengthIndicator +import de.davis.keygo.core.item.presentation.toImageVector import de.davis.keygo.core.ui.components.KeyGoCard import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode import de.davis.keygo.feature.item.core.presentation.component.CopyToClipboardButton @@ -97,7 +105,30 @@ fun ViewPasswordContent(state: ViewPasswordState, onEvent: (ViewPasswordUiEvent) Text(text = state.name) }, subtitle = { - Text(text = stringResource(CoreItemR.string.password)) + state.vaultMetadata?.let { metadata -> + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider( + LocalContentColor provides MaterialTheme.colorScheme.onSecondaryContainer + ) { + val textStyle = LocalTextStyle.current + val size = with(LocalDensity.current) { + if (textStyle.fontSize.isSp) textStyle.fontSize.toDp() + else 24.dp + } + Icon( + imageVector = metadata.icon.toImageVector(), + contentDescription = null, + modifier = Modifier.size(size), + ) + Text(text = metadata.name) + } + Text(text = "\u2022") + Text(text = stringResource(CoreItemR.string.password)) + } + } }, navigationIcon = { if (LocalIsInSinglePaneMode.current) { @@ -476,7 +507,7 @@ private fun ViewPasswordContentPreview() { username = "Username 1", domains = setOf( DomainInfo( - passwordId = 1, + passwordId = newItemId(), value = "login.example.com", eTLD1 = "example.com" ) @@ -504,7 +535,7 @@ private fun ViewPasswordContentModificationDialogPreview() { username = "Username 1", domains = setOf( DomainInfo( - passwordId = 1, + passwordId = newItemId(), value = "login.example.com", eTLD1 = "example.com" ) diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/ViewPasswordViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/ViewPasswordViewModel.kt index 21ede864..be771778 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/ViewPasswordViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/ViewPasswordViewModel.kt @@ -3,13 +3,13 @@ package de.davis.keygo.feature.item.view.password import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.item.domain.alias.ItemId -import de.davis.keygo.core.item.domain.alias.ItemIdNone -import de.davis.keygo.core.item.domain.crypto.decryptSecretData import de.davis.keygo.core.item.domain.model.DomainInfo -import de.davis.keygo.core.item.domain.repository.PasswordRepository -import de.davis.keygo.core.item.domain.repository.VaultItemRepository +import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.decryptSecretData +import de.davis.keygo.core.security.domain.usecase.PasswordWithCryptoScopeUseCase import de.davis.keygo.core.util.domain.resolver.RegistrableDomainResolver import de.davis.keygo.core.util.getOrNull import de.davis.keygo.core.util.onFailure @@ -41,7 +41,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -55,78 +54,88 @@ import org.koin.core.annotation.KoinViewModel @KoinViewModel internal class ViewPasswordViewModel( - private val vaultItemRepository: VaultItemRepository, - private val passwordRepository: PasswordRepository, - private val cryptographicScopeProvider: CryptographicScopeProvider, + private val itemRepository: ItemRepository, + private val vaultRepository: VaultRepository, private val updatePassword: CreateNewOrUpdatePasswordUseCase, private val isValidUrl: IsValidUrlUseCase, private val websiteHandler: WebsiteHandler, private val totpGenerator: TotpGenerator, private val registrableDomainResolver: RegistrableDomainResolver, - private val getTotpSecret: GetTotpSecretFromUrlUseCase + private val getTotpSecret: GetTotpSecretFromUrlUseCase, + private val observePasswordWithCryptoScope: PasswordWithCryptoScopeUseCase, ) : ViewModel() { private val _modificationDialogState = MutableStateFlow(null) private val _scanning = MutableStateFlow(false) - private val _itemId = MutableStateFlow(ItemIdNone) + private val _itemId = MutableStateFlow(null) @OptIn(ExperimentalCoroutinesApi::class) - private val stateWithoutModification = _itemId - .filter { it != ItemIdNone } + private val _stateWithoutModification = _itemId + .filterNotNull() .distinctUntilChanged() .flatMapLatest { id -> - passwordRepository.observePasswordById(id).filterNotNull().flatMapLatest { password -> - coroutineScope { - val obfuscatedString = async { - cryptographicScopeProvider.scope { - password.encryptedData.decryptSecretData() - }.asObfuscatedString() + observePasswordWithCryptoScope.observe(itemId = id) { password -> + val (obfuscated, totp, vaultMetadata) = coroutineScope { + val obfuscated = async { + password.password.decryptSecretData(label = Password.LABEL_PASSWORD) + .asObfuscatedString() } - - val totpSecret = password.totpSecret?.let { totpSecret -> + val totp = password.totpSecret?.let { totpSecret -> async { - cryptographicScopeProvider.scope { - totpSecret.decryptSecretData().encodeToByteArray() - } + totpSecret.decryptSecretData(label = Password.LABEL_TOTP_SECRET) + .encodeToByteArray() } } + val vaultMetadata = async { + vaultRepository.getVaultMetadata(password.vaultId) + } - - val base = ViewPasswordState( - name = password.name, - passkeyRPs = password.passkeyRPs, - password = obfuscatedString.await(), - passwordStrengthScore = password.score, - username = password.username.orEmpty(), - domains = password.domainInfos, - note = password.note.orEmpty(), - totpInformation = TotpInformation("", 0, 0), - pinned = password.pinned, + Triple( + obfuscated.await(), + totp?.await(), + vaultMetadata.await() ) + } - when { - totpSecret == null -> flowOf(base) - else -> totpGenerator.observeTotp(totpSecret.await()).map { - base.copy(totpInformation = it) - } + val base = ViewPasswordState( + name = password.name, + vaultMetadata = vaultMetadata, + passkeyRPs = password.passkeyRPs, + password = obfuscated, + passwordStrengthScore = password.score, + username = password.username.orEmpty(), + domains = password.domainInfos, + note = password.note.orEmpty(), + totpInformation = TotpInformation("", 0, 0), + pinned = password.pinned, + ) + + when (val totpSecret = totp) { + null -> flowOf(base) + else -> totpGenerator.observeTotp(totpSecret).map { + base.copy(totpInformation = it) } } } + .filterNotNull() + .flatMapLatest { it } }.flowOn(Dispatchers.Default) - val state = - combine( - stateWithoutModification, - _modificationDialogState, - _scanning - ) { state, modificationDialog, scanning -> - state.copy(modificationDialog = modificationDialog, scanning = scanning) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = ViewPasswordState() + val state = combine( + _stateWithoutModification, + _modificationDialogState, + _scanning, + ) { state, modificationDialog, scanning -> + state.copy( + modificationDialog = modificationDialog, + scanning = scanning, ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ViewPasswordState() + ) private val navigationEventChannel = Channel() val navigationEvent = navigationEventChannel.receiveAsFlow() @@ -155,19 +164,23 @@ internal class ViewPasswordViewModel( } ViewPasswordUiEvent.OnPinClick -> { - viewModelScope.launch { - vaultItemRepository.setPinned(_itemId.value, !state.value.pinned) + _itemId.value?.let { id -> + viewModelScope.launch { + itemRepository.setPinned(id, !state.value.pinned) + } } } ViewPasswordUiEvent.OnEditRequest -> { - viewModelScope.launch { - navigationEventChannel.send( - NavigationEvent.NavigateToEdit( - VaultItemType.Password, - _itemId.value + _itemId.value?.let { id -> + viewModelScope.launch { + navigationEventChannel.send( + NavigationEvent.NavigateToEdit( + VaultItemType.Password, + id + ) ) - ) + } } } @@ -206,74 +219,76 @@ internal class ViewPasswordViewModel( getTotpSecret(it).getOrNull() }?.secret ?: return - val itemId = _itemId.value - viewModelScope.launch { - updatePassword( - UpsertPassword.update( - vaultId = itemId, - totpSecret = fieldUpdate(secret) + _itemId.value?.let { id -> + viewModelScope.launch { + updatePassword( + UpsertPassword.update( + itemId = id, + totpSecret = fieldUpdate(secret) + ) ) - ) + } } } is ViewPasswordUiEvent.OnSubmitModification -> { val dialog = _modificationDialogState.value ?: return - val itemId = _itemId.value val newText = fieldUpdate(event.input) - viewModelScope.launch { - updatePassword( - when (dialog.fieldType) { - FieldType.Name -> UpsertPassword.update( - vaultId = itemId, - name = newText - ) - - FieldType.Password -> UpsertPassword.update( - vaultId = itemId, - password = newText - ) - - FieldType.Totp -> UpsertPassword.update( - vaultId = itemId, - totpSecret = newText - ) + _itemId.value?.let { id -> + viewModelScope.launch { + updatePassword( + when (dialog.fieldType) { + FieldType.Name -> UpsertPassword.update( + itemId = id, + name = newText + ) - FieldType.Username -> UpsertPassword.update( - vaultId = itemId, - username = newText - ) + FieldType.Password -> UpsertPassword.update( + itemId = id, + password = newText + ) - FieldType.Domain -> newText.onSet { - val eTLD1 = registrableDomainResolver.resolve(it) - val updatedDomains = state.value.domains + DomainInfo( - itemId, - it, - eTLD1 + FieldType.Totp -> UpsertPassword.update( + itemId = id, + totpSecret = newText ) - UpsertPassword.update( - vaultId = itemId, - domains = set(updatedDomains) + FieldType.Username -> UpsertPassword.update( + itemId = id, + username = newText ) - } ?: return@launch - FieldType.Note -> UpsertPassword.update( - vaultId = itemId, - note = newText - ) - } - ).onFailure { failure -> - _modificationDialogState.update { - dialog.copy( - error = if (failure.contains(PasswordError.BlankPassword) - || failure.contains(PasswordError.BlankName) - ) InputFieldError.Empty else null - ) + FieldType.Domain -> newText.onSet { + val eTLD1 = registrableDomainResolver.resolve(it) + val updatedDomains = state.value.domains + DomainInfo( + id, + it, + eTLD1 + ) + + UpsertPassword.update( + itemId = id, + domains = set(updatedDomains) + ) + } ?: return@launch + + FieldType.Note -> UpsertPassword.update( + itemId = id, + note = newText + ) + } + ).onFailure { failure -> + _modificationDialogState.update { + dialog.copy( + error = if (failure.contains(PasswordError.BlankPassword) + || failure.contains(PasswordError.BlankName) + ) InputFieldError.Empty else null + ) + } + }.onSuccess { + _modificationDialogState.update { null } } - }.onSuccess { - _modificationDialogState.update { null } } } } diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/model/ViewPasswordState.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/model/ViewPasswordState.kt index 9a55f979..d48e335f 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/model/ViewPasswordState.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/password/model/ViewPasswordState.kt @@ -3,12 +3,14 @@ package de.davis.keygo.feature.item.view.password.model import androidx.compose.runtime.Immutable import de.davis.keygo.core.item.domain.model.DomainInfo import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.model.VaultMetadata import de.davis.keygo.feature.totp.domain.model.TotpInformation @Immutable data class ViewPasswordState( val name: String = "", + val vaultMetadata: VaultMetadata? = null, val passkeyRPs: Set = emptySet(), val password: ObfuscatedString = ObfuscatedString(""), val passwordStrengthScore: Password.Score = Password.Score.None, diff --git a/feature/list_screen/build.gradle.kts b/feature/list_screen/build.gradle.kts index fd69c306..2558f4f1 100644 --- a/feature/list_screen/build.gradle.kts +++ b/feature/list_screen/build.gradle.kts @@ -54,17 +54,22 @@ dependencies { implementation(projects.core.item) implementation(projects.core.ui) implementation(projects.core.util) + implementation(projects.core.security) + implementation(projects.feature.vault) // Koin DI implementation(project.dependencies.platform(libs.koin.bom)) implementation(libs.koin.androidx.compose) implementation(libs.koin.annotations) - testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.io.mockk) + testImplementation(testFixtures(projects.core.item)) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.test.manifest) -} \ No newline at end of file +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/di/FeatureListScreenModule.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/di/FeatureListScreenModule.kt index c76c2668..0a5c68df 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/di/FeatureListScreenModule.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/di/FeatureListScreenModule.kt @@ -7,4 +7,4 @@ import org.koin.core.annotation.Module @Module @Configuration @ComponentScan("de.davis.keygo.feature.list_screen") -object FeatureListScreenModule \ No newline at end of file +object FeatureListScreenModule diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt index d484ae21..b5dcec0e 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCase.kt @@ -46,7 +46,7 @@ class FilterUseCase { if (filterState.selectedScores.isEmpty()) return true if (item.itemType != VaultItemType.Password) return true // non-password items - pass through - val score = passwordScores[item.vaultItemId] ?: return false + val score = passwordScores[item.id] ?: return false return score in filterState.selectedScores } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt index b131754e..81a34dcb 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListScreen.kt @@ -1,66 +1,26 @@ package de.davis.keygo.feature.list_screen.presentation -import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.FilterList -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.AppBarWithSearch -import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox -import androidx.compose.material3.ExpandedDockedSearchBar -import androidx.compose.material3.ExpandedFullScreenSearchBar import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarScrollBehavior import androidx.compose.material3.SearchBarValue -import androidx.compose.material3.Text import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.generated.domain.model.VaultItemType -import de.davis.keygo.core.item.generated.presentation.presentation -import de.davis.keygo.core.ui.components.HeaderContent -import de.davis.keygo.core.ui.components.KeyGoCard -import de.davis.keygo.core.ui.components.KeyGoCardProperties -import de.davis.keygo.core.ui.components.KeyGoColumn -import de.davis.keygo.core.ui.components.KeyGoColumnItem import de.davis.keygo.core.util.presentation.ObserveAsEvents -import de.davis.keygo.feature.list_screen.R -import de.davis.keygo.feature.list_screen.presentation.components.FilterBottomSheet -import de.davis.keygo.feature.list_screen.presentation.components.SearchResult +import de.davis.keygo.feature.list_screen.presentation.components.ItemListContent import de.davis.keygo.feature.list_screen.presentation.model.Event -import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf -import de.davis.keygo.core.ui.R as CoreUiR + @Stable sealed interface NoItemStrategy { @@ -89,9 +49,6 @@ fun ItemListScreen( } val uiState by viewModel.listItemState.collectAsStateWithLifecycle() val filterSheetState by viewModel.filterBottomSheetState.collectAsStateWithLifecycle() - var showFilterSheet by rememberSaveable { mutableStateOf(false) } - - val searchBarState = rememberSearchBarState() LaunchedEffect(autoSelectFirst) { if (!autoSelectFirst) viewModel.resetHighlight() @@ -99,19 +56,7 @@ fun ItemListScreen( LaunchedEffect(uiState.items, uiState.highlightedId, autoSelectFirst) { if (autoSelectFirst && uiState.highlightedId == null && uiState.items.isNotEmpty()) - viewModel.onItemClick(uiState.items.first().vaultItemId, forceSkipSelection = true) - } - - // In case the user types something, but does not submit the search, we rollback to the last - // submitted search query. - ObserveAsEvents(snapshotFlow { searchBarState.targetValue }, searchBarState) { - when (it) { - SearchBarValue.Collapsed -> { - viewModel.resetToMatchSubmittedQuery() - } - - else -> {} - } + viewModel.onItemClick(uiState.items.first().id, forceSkipSelection = true) } val currentOnItemDelete by rememberUpdatedState(onItemDelete) @@ -131,193 +76,41 @@ fun ItemListScreen( } } - val scope = rememberCoroutineScope() - val searchInputField = @Composable { - SearchBarDefaults.InputField( - textFieldState = viewModel.searchTextFieldState, - searchBarState = searchBarState, - onSearch = { - viewModel.onSubmitQuery() - scope.launch { searchBarState.animateToCollapsed() } - }, - enabled = false, // TODO: remove when b/464761441 is fixed - modifier = Modifier.fillMaxWidth(), - leadingIcon = { - AnimatedContent( - targetState = !uiState.hasSearchQuery && searchBarState.targetValue == SearchBarValue.Collapsed - ) { - when (it) { - true -> Icon( - imageVector = Icons.Default.Search, - contentDescription = null - ) - - false -> IconButton( - onClick = { - viewModel.onClearQuery() - scope.launch { searchBarState.animateToCollapsed() } - } - ) { - Icon( - imageVector = Icons.AutoMirrored.Default.ArrowBack, - contentDescription = null - ) - } - } - } - }, - trailingIcon = { - IconButton(onClick = { showFilterSheet = true }) { - BadgedBox( - badge = { - if (!filterSheetState.isDefault) Badge() - } - ) { - Icon( - imageVector = Icons.Default.FilterList, - contentDescription = stringResource(R.string.filter), - ) - } - } - }, - placeholder = { - Text(text = stringResource(R.string.search_your_vault)) - } - ) - } - if (showFilterSheet) - FilterBottomSheet( - state = filterSheetState, - onAction = viewModel::onFilterAction, - onDismiss = { showFilterSheet = false }, - ) - - Scaffold( - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { - AppBarWithSearch( - state = searchBarState, - inputField = searchInputField, - scrollBehavior = scrollBehavior - ) - - val searchResultContent: @Composable ColumnScope.() -> Unit = { - val scope = rememberCoroutineScope() - SearchResult( - searchResult = uiState.searchResults, - idOf = { it.vaultItemId }, - nameOf = { it.name }, - matchedInName = { true }, - matchedInNote = { false }, - onClick = { item -> - scope.launch { searchBarState.animateToCollapsed() } - // Clicking a search result should not select the item when currently - // other items are selected. - viewModel.onItemClick(item.vaultItemId, forceSkipSelection = true) - }, - modifier = Modifier.padding(8.dp) - ) - } - when (dockedSearchResults) { - true -> ExpandedDockedSearchBar( - state = searchBarState, - inputField = searchInputField, - content = searchResultContent - ) + val searchBarState = rememberSearchBarState() - false -> ExpandedFullScreenSearchBar( - state = searchBarState, - inputField = searchInputField, - content = searchResultContent - ) + // In case the user types something, but does not submit the search, we rollback to the last + // submitted search query. + ObserveAsEvents(snapshotFlow { searchBarState.targetValue }, searchBarState) { + when (it) { + SearchBarValue.Collapsed -> { + viewModel.resetToMatchSubmittedQuery() } - } - ) { innerPadding -> - AnimatedContent( - targetState = uiState.items.isEmpty(), - modifier = Modifier - .padding(innerPadding) - .padding(top = 4.dp) - ) { isEmpty -> - when (isEmpty) { - true -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - val showCreateCard = - !uiState.hasSearchQuery && notFoundStrategy is NoItemStrategy.ShowCreateNewItemCard - when (showCreateCard) { - true -> { - val createTypes = remember(restrictedItemType) { - restrictedItemType?.let { listOf(it) } - ?: VaultItemType.entries - } - - KeyGoCard( - title = { - Text(text = stringResource(CoreUiR.string.create_new_item)) - }, - modifier = Modifier.fillMaxWidth(), - properties = KeyGoCardProperties.elevated() - ) { - createTypes.forEach { - FilledTonalButton( - onClick = { onCreateItemRequest(it) }, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = it.presentation.first) - } - } - } - } - false -> Text(text = stringResource(CoreUiR.string.match_not_found)) - } - } - } - - false -> { - val items = remember(uiState.items) { - uiState.items.map { - KeyGoColumnItem( - header = if (it.pinned) HeaderContent.Pin - else HeaderContent.Letter(it.name.first().uppercaseChar()), - title = it.name, - id = it.vaultItemId, - itemType = it.itemType, - ) - } - } - - KeyGoColumn( - items = items, - onDelete = viewModel::onDelete, - onItemClick = viewModel::onItemClick, - onItemLongClick = viewModel::onItemLongClick, - modifier = Modifier.padding(horizontal = 8.dp), - enableSwipeToDelete = enableDeletion, - openedItemId = if (autoSelectFirst) uiState.highlightedId else null, - selectedItemIds = uiState.selectedItemIds - ) - } - } + else -> {} } } -} -@OptIn(ExperimentalMaterial3Api::class) -@Preview -@Composable -private fun ItemListScreenPreview() { - MaterialTheme { - ItemListScreen( - onItemClick = { }, - onItemLongClick = { }, - onCreateItemRequest = {}, - ) - } -} \ No newline at end of file + ItemListContent( + uiState = uiState, + searchBarState = searchBarState, + searchTextFieldState = viewModel.searchTextFieldState, + filterBottomSheetState = filterSheetState, + dockedSearchResults = dockedSearchResults, + enableDeletion = enableDeletion, + autoSelectFirst = autoSelectFirst, + notFoundStrategy = notFoundStrategy, + restrictedItemType = restrictedItemType, + onCreateItemRequest = onCreateItemRequest, + onSubmitQuery = viewModel::onSubmitQuery, + onClearQuery = viewModel::onClearQuery, + onFilterAction = viewModel::onFilterAction, + onItemClick = viewModel::onItemClick, + onItemLongClick = viewModel::onItemLongClick, + onDelete = viewModel::onDelete, + scrollBehavior = scrollBehavior, + onVaultSelectorClick = viewModel::onVaultSelectorClick, + onDismissVaultFlow = viewModel::onDismissVaultFlow, + modifier = modifier + ) +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt index 6330f19d..1016df0b 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt @@ -7,9 +7,10 @@ import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.getIdOrNull import de.davis.keygo.core.item.domain.model.lite.LiteItem +import de.davis.keygo.core.item.domain.repository.ItemRepository import de.davis.keygo.core.item.domain.repository.PasswordRepository -import de.davis.keygo.core.item.domain.repository.VaultItemRepository import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.util.domain.snackbar.SnackbarManager import de.davis.keygo.feature.list_screen.domain.model.FilterState @@ -20,6 +21,7 @@ import de.davis.keygo.feature.list_screen.presentation.model.Event import de.davis.keygo.feature.list_screen.presentation.model.FilterAction import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState import de.davis.keygo.feature.list_screen.presentation.model.ListItemState +import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -52,12 +54,18 @@ internal class ItemListViewModel( @InjectedParam private val enableSelection: Boolean, @InjectedParam private val restrictedItemType: VaultItemType?, private val snackbarManager: SnackbarManager, - private val vaultItemRepository: VaultItemRepository, + private val itemRepository: ItemRepository, private val filterUseCase: FilterUseCase, + observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase, passwordRepository: PasswordRepository, ) : ViewModel() { - private val allItems = vaultItemRepository.observeLiteVaultItems() + private val vaultsAndSelection = observeVaultsAndSelection() + + @OptIn(ExperimentalCoroutinesApi::class) + private val vaultSpecificItems = vaultsAndSelection.flatMapLatest { vaultsAndSelection -> + itemRepository.observeLiteVaultItems(vaultsAndSelection.selection.getIdOrNull()) + } private val submittedSearchQuery = MutableStateFlow("") @@ -69,10 +77,9 @@ internal class ItemListViewModel( itemSource, flaggedForDeletion ) { items, flagged -> - items.filterNot { item -> item.vaultItemId in flagged } + items.filterNot { item -> item.id in flagged } }.distinctUntilChanged() - private val passwordScores = passwordRepository.observePasswordScores() private val filterState = MutableStateFlow(FilterState.Default) @@ -87,28 +94,35 @@ internal class ItemListViewModel( private val searchResults = MutableStateFlow(listOf()) private val selectedItemIds = MutableStateFlow(emptySet()) private val highlightedId = MutableStateFlow(null) + private val _isVaultFlowVisible = MutableStateFlow(false) - val listItemState = combine( + val listItemState = combine7( + vaultsAndSelection, filteredItems, searchResults, selectedItemIds, submittedSearchQuery, highlightedId, - ) { items, searchResults, selectedIds, submittedSearchQuery, highlightedId -> + _isVaultFlowVisible, + ) { vaultsAndSel, items, searchResults, selectedIds, submittedSearchQuery, highlightedId, isVaultFlowVisible -> ListItemState( items = items, searchResults = searchResults, hasSearchQuery = submittedSearchQuery.isNotBlank(), selectedItemIds = selectedIds, highlightedId = highlightedId, + isVaultFlowVisible = isVaultFlowVisible, + vaults = vaultsAndSel.vaults, + vaultContext = vaultsAndSel.selection, + ) + }.distinctUntilChanged() + .onStart { + observeSearchState() + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ListItemState() ) - }.onStart { - observeSearchState() - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = ListItemState() - ) private val availableFilterOptions = combine( nonDeletedItems, @@ -159,19 +173,30 @@ internal class ItemListViewModel( } } + fun onVaultSelectorClick() { + _isVaultFlowVisible.update { true } + } + + fun onDismissVaultFlow() { + _isVaultFlowVisible.update { false } + } + private fun Set.toggle(element: T): Set = if (element in this) this - element else this + element - private suspend fun queryToItems(query: String): Flow> = - (if (query.isBlank()) allItems - else flowOf(vaultItemRepository.searchVaultItem(query, restrictedItemType))) + private suspend fun queryToItems( + query: String, + forceSearchAllVaults: Boolean = false + ): Flow> = + (if (!forceSearchAllVaults && query.isBlank()) vaultSpecificItems + else flowOf(itemRepository.searchVaultItem(query, restrictedItemType))) @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) private fun observeSearchState() { snapshotFlow { searchTextFieldState.text } .debounce(300.milliseconds) .flatMapLatest { - queryToItems(it.toString()) + queryToItems(it.toString(), forceSearchAllVaults = true) } .distinctUntilChanged() .onEach { items -> @@ -202,7 +227,7 @@ internal class ItemListViewModel( updateItemSelectionState(itemId, selected = false) updateItemDeletionState(itemId, deleted = true) - val firstItemId = listItemState.value.items.firstOrNull()?.vaultItemId + val firstItemId = listItemState.value.items.firstOrNull()?.id if (highlightedId.value == itemId) highlightedId.update { firstItemId } @@ -215,7 +240,7 @@ internal class ItemListViewModel( }, onDismiss = { viewModelScope.launch { - vaultItemRepository.deleteItem(itemId) + itemRepository.deleteItem(itemId) // Inside this coroutine to ensure it only runs after the deletion updateItemDeletionState(itemId, deleted = false) @@ -264,9 +289,31 @@ internal class ItemListViewModel( if (pendingDeletions.isNotEmpty()) { CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { pendingDeletions.forEach { itemId -> - vaultItemRepository.deleteItem(itemId) + itemRepository.deleteItem(itemId) } } } } } + +private fun combine7( + flow1: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + flow7: Flow, + transform: (T1, T2, T3, T4, T5, T6, T7) -> R +): Flow = combine(flow1, flow2, flow3, flow4, flow5, flow6, flow7) { arrayOfFlows -> + @Suppress("UNCHECKED_CAST") + transform( + arrayOfFlows[0] as T1, + arrayOfFlows[1] as T2, + arrayOfFlows[2] as T3, + arrayOfFlows[3] as T4, + arrayOfFlows[4] as T5, + arrayOfFlows[5] as T6, + arrayOfFlows[6] as T7, + ) +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/VaultIcon.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/VaultIcon.kt new file mode 100644 index 00000000..afaba635 --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/VaultIcon.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.feature.list_screen.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.presentation.toImageVector +import de.davis.keygo.feature.list_screen.presentation.model.ListItemState +import de.davis.keygo.feature.vault.presentation.AllVaultsIcon + +@Composable +internal fun ListItemState.selectedVaultIcon(): Painter? = when (vaultContext) { + VaultContext.NoSpecific -> AllVaultsIcon + is VaultContext.ById -> vaults.firstOrNull { it.vaultId == vaultContext.vaultId } + ?.icon + ?.toImageVector() + ?.let { rememberVectorPainter(it) } +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt index 2950a6cd..7a526f05 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/FilterBottomSheet.kt @@ -8,32 +8,28 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Category -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Password import androidx.compose.material3.ButtonGroupDefaults +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedCard import androidx.compose.material3.SheetState -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.ToggleButton import androidx.compose.material3.ToggleButtonDefaults -import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -50,6 +46,7 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.item.generated.presentation.presentation import de.davis.keygo.core.ui.components.KeyGoCard import de.davis.keygo.core.ui.components.KeyGoCardProperties +import de.davis.keygo.core.ui.components.KeyGoSwitch import de.davis.keygo.core.ui.theme.KeyGoTheme import de.davis.keygo.feature.list_screen.R import de.davis.keygo.feature.list_screen.domain.model.SortDirection @@ -79,7 +76,6 @@ internal fun FilterBottomSheet( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun FilterBottomSheetContent( state: FilterBottomSheetState, @@ -159,38 +155,20 @@ private fun ItemSection( ) if (state.showPinnedSwitch) { - OutlinedCard( - modifier = Modifier - .minimumInteractiveComponentSize() - .toggleable( - value = state.onlyPinnedChecked, - role = Role.Switch, - onValueChange = { onAction(FilterAction.ShowOnlyPinnedToggled) } - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + OutlinedCard { + KeyGoSwitch( + checked = state.onlyPinnedChecked, + onCheckedChange = { onAction(FilterAction.ShowOnlyPinnedToggled) }, + shapes = ListItemDefaults.shapes( + shape = CardDefaults.outlinedShape, + pressedShape = CardDefaults.outlinedShape, + draggedShape = CardDefaults.outlinedShape, + focusedShape = CardDefaults.outlinedShape, + hoveredShape = CardDefaults.outlinedShape, + selectedShape = CardDefaults.outlinedShape, + ) ) { Text(text = stringResource(R.string.only_pinned_items)) - Spacer(modifier = Modifier.width(4.dp)) - Switch( - checked = state.onlyPinnedChecked, - onCheckedChange = null, - thumbContent = { - Icon( - imageVector = when { - state.onlyPinnedChecked -> Icons.Default.Check - else -> Icons.Default.Close - }, - contentDescription = null, - modifier = Modifier.size(SwitchDefaults.IconSize), - ) - } - ) } } } @@ -289,7 +267,6 @@ private fun SortSection( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun PasswordSection( state: PasswordSectionState, @@ -372,41 +349,38 @@ private fun SortDirection.icon(): ImageVector = when (this) { private val DefaultHorizontalArrangement get() = Arrangement.spacedBy(8.dp) -@OptIn(ExperimentalMaterial3Api::class) @Preview @Composable private fun FilterBottomSheetContentPreview() { KeyGoTheme { - FilterBottomSheet( - state = FilterBottomSheetState( - sortDirection = SortDirection.Ascending, - itemSection = ItemSectionState( - showPinnedSwitch = true, - onlyPinnedChecked = true, - itemTypeChips = VaultItemType.entries.map { type -> - FilterChipState(value = type, selected = false) - }, - labelChips = listOf( - FilterChipState(value = "Label1", selected = false), - FilterChipState(value = "Label2", selected = true), + Surface { + FilterBottomSheetContent( + state = FilterBottomSheetState( + sortDirection = SortDirection.Ascending, + itemSection = ItemSectionState( + showPinnedSwitch = true, + onlyPinnedChecked = true, + itemTypeChips = VaultItemType.entries.map { type -> + FilterChipState(value = type, selected = false) + }, + labelChips = listOf( + FilterChipState(value = "Label1", selected = false), + FilterChipState(value = "Label2", selected = true), + ), ), - ), - passwordSection = PasswordSectionState( - scoreChips = listOf( - FilterChipState(value = Password.Score.Excellent, selected = false), - FilterChipState(value = Password.Score.Strong, selected = false), - FilterChipState(value = Password.Score.Moderate, selected = true), - FilterChipState(value = Password.Score.Weak, selected = true), - FilterChipState(value = Password.Score.Ridiculous, selected = false), + passwordSection = PasswordSectionState( + scoreChips = listOf( + FilterChipState(value = Password.Score.Excellent, selected = false), + FilterChipState(value = Password.Score.Strong, selected = false), + FilterChipState(value = Password.Score.Moderate, selected = true), + FilterChipState(value = Password.Score.Weak, selected = true), + FilterChipState(value = Password.Score.Ridiculous, selected = false), + ), ), + isDefault = false, ), - isDefault = false, - ), - onAction = {}, - onDismiss = {}, - sheetState = rememberModalBottomSheetState( - skipPartiallyExpanded = true, - ), - ) + onAction = {}, + ) + } } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt new file mode 100644 index 00000000..a9fa5808 --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt @@ -0,0 +1,288 @@ +package de.davis.keygo.feature.list_screen.presentation.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.AppBarWithSearch +import androidx.compose.material3.ExpandedDockedSearchBar +import androidx.compose.material3.ExpandedFullScreenSearchBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.SearchBarScrollBehavior +import androidx.compose.material3.SearchBarState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberSearchBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult +import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.item.generated.presentation.presentation +import de.davis.keygo.core.ui.R +import de.davis.keygo.core.ui.components.HeaderContent +import de.davis.keygo.core.ui.components.KeyGoCard +import de.davis.keygo.core.ui.components.KeyGoCardProperties +import de.davis.keygo.core.ui.components.KeyGoColumn +import de.davis.keygo.core.ui.components.KeyGoColumnItem +import de.davis.keygo.feature.list_screen.domain.model.SortDirection +import de.davis.keygo.feature.list_screen.presentation.NoItemStrategy +import de.davis.keygo.feature.list_screen.presentation.model.FilterAction +import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState +import de.davis.keygo.feature.list_screen.presentation.model.ItemSectionState +import de.davis.keygo.feature.list_screen.presentation.model.ListItemState +import de.davis.keygo.feature.vault.presentation.VaultFlow +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ItemListContent( + uiState: ListItemState, + searchBarState: SearchBarState, + searchTextFieldState: TextFieldState, + filterBottomSheetState: FilterBottomSheetState, + dockedSearchResults: Boolean, + enableDeletion: Boolean, + autoSelectFirst: Boolean, + notFoundStrategy: NoItemStrategy, + restrictedItemType: VaultItemType?, + onCreateItemRequest: (VaultItemType) -> Unit, + onSubmitQuery: () -> Unit, + onClearQuery: () -> Unit, + onFilterAction: (FilterAction) -> Unit, + onItemClick: (ItemId, forceSkipSelection: Boolean) -> Unit, + onItemLongClick: (ItemId) -> Unit, + onDelete: (ItemId) -> Unit, + onVaultSelectorClick: () -> Unit, + onDismissVaultFlow: () -> Unit, + scrollBehavior: SearchBarScrollBehavior, + modifier: Modifier = Modifier +) { + var showFilterSheet by rememberSaveable { mutableStateOf(false) } + + val searchInputField = @Composable { + ListSearchTextField( + searchTextFieldState = searchTextFieldState, + searchBarState = searchBarState, + uiState = uiState, + onSubmitQuery = onSubmitQuery, + onClearQuery = onClearQuery, + onShowFilterClick = { showFilterSheet = true }, + onVaultSelectorClick = onVaultSelectorClick, + filterBottomSheetState = filterBottomSheetState, + ) + } + + if (showFilterSheet) + FilterBottomSheet( + state = filterBottomSheetState, + onAction = onFilterAction, + onDismiss = { showFilterSheet = false }, + ) + + if (uiState.isVaultFlowVisible) + VaultFlow(onDismiss = onDismissVaultFlow) + + Scaffold( + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + AppBarWithSearch( + state = searchBarState, + inputField = searchInputField, + scrollBehavior = scrollBehavior + ) + + val searchResultContent: @Composable ColumnScope.() -> Unit = { + val scope = rememberCoroutineScope() + SearchResult( + searchResult = uiState.searchResults, + idOf = { it.id }, + nameOf = { it.name }, + matchedInName = { true }, + matchedInNote = { false }, + onClick = { item -> + scope.launch { searchBarState.animateToCollapsed() } + + // Clicking a search result should not select the item when currently + // other items are selected. + onItemClick(item.id, true) + }, + modifier = Modifier.padding(8.dp) + ) + } + when (dockedSearchResults) { + true -> ExpandedDockedSearchBar( + state = searchBarState, + inputField = searchInputField, + content = searchResultContent + ) + + false -> ExpandedFullScreenSearchBar( + state = searchBarState, + inputField = searchInputField, + content = searchResultContent + ) + } + } + ) { innerPadding -> + AnimatedContent( + targetState = uiState.items.isEmpty(), + modifier = Modifier + .padding(innerPadding) + .padding(top = 4.dp) + ) { isEmpty -> + when (isEmpty) { + true -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + val showCreateCard = + !uiState.hasSearchQuery && notFoundStrategy is NoItemStrategy.ShowCreateNewItemCard + when (showCreateCard) { + true -> { + val createTypes = remember(restrictedItemType) { + restrictedItemType?.let { listOf(it) } + ?: VaultItemType.entries + } + + KeyGoCard( + title = { + Text(text = stringResource(R.string.create_new_item)) + }, + modifier = Modifier.fillMaxWidth(), + properties = KeyGoCardProperties.elevated() + ) { + createTypes.forEach { + FilledTonalButton( + onClick = { onCreateItemRequest(it) }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = it.presentation.first) + } + } + } + } + + false -> Text(text = stringResource(R.string.match_not_found)) + } + } + } + + false -> { + val items = remember(uiState.items) { + uiState.items.map { + KeyGoColumnItem( + header = if (it.pinned) HeaderContent.Pin + else HeaderContent.Letter(it.name.first().uppercaseChar()), + title = it.name, + id = it.id, + itemType = it.itemType, + ) + } + } + + KeyGoColumn( + items = items, + onDelete = onDelete, + onItemClick = { onItemClick(it, false) }, + onItemLongClick = onItemLongClick, + modifier = Modifier.padding(horizontal = 8.dp), + enableSwipeToDelete = enableDeletion, + openedItemId = if (autoSelectFirst) uiState.highlightedId else null, + selectedItemIds = uiState.selectedItemIds + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun ItemListContentPreview() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + val sampleItem = remember { + LiteItemSearchResult( + id = newItemId(), + name = "Sample Item", + itemType = VaultItemType.Password, + pinned = false, + matchedName = true, + matchedNote = false, + ) + } + + val uiState = remember { + ListItemState( + items = listOf(sampleItem), + searchResults = listOf(sampleItem), + hasSearchQuery = false, + highlightedId = null, + selectedItemIds = emptySet(), + ) + } + val searchTextFieldState = rememberTextFieldState() + val filterBottomSheetState = remember { + FilterBottomSheetState( + sortDirection = SortDirection.Ascending, + itemSection = ItemSectionState( + showPinnedSwitch = false, + onlyPinnedChecked = false, + itemTypeChips = emptyList(), + labelChips = emptyList() + ), + passwordSection = null, + isDefault = true + ) + } + val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() + + ItemListContent( + uiState = uiState, + searchBarState = rememberSearchBarState(), + searchTextFieldState = searchTextFieldState, + filterBottomSheetState = filterBottomSheetState, + dockedSearchResults = false, + enableDeletion = true, + autoSelectFirst = false, + notFoundStrategy = NoItemStrategy.ShowCreateNewItemCard, + restrictedItemType = null, + onCreateItemRequest = {}, + onSubmitQuery = {}, + onClearQuery = {}, + onFilterAction = {}, + onItemClick = { _, _ -> }, + onItemLongClick = {}, + onDelete = {}, + onVaultSelectorClick = {}, + onDismissVaultFlow = {}, + scrollBehavior = scrollBehavior + ) + } + } +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ListSearchTextField.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ListSearchTextField.kt new file mode 100644 index 00000000..6866f6fd --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ListSearchTextField.kt @@ -0,0 +1,116 @@ +package de.davis.keygo.feature.list_screen.presentation.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.SearchBarState +import androidx.compose.material3.SearchBarValue +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import de.davis.keygo.feature.list_screen.R +import de.davis.keygo.feature.list_screen.presentation.model.FilterBottomSheetState +import de.davis.keygo.feature.list_screen.presentation.model.ListItemState +import de.davis.keygo.feature.list_screen.presentation.selectedVaultIcon +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +internal fun ListSearchTextField( + searchTextFieldState: TextFieldState, + searchBarState: SearchBarState, + uiState: ListItemState, + onSubmitQuery: () -> Unit, + onClearQuery: () -> Unit, + onShowFilterClick: () -> Unit, + onVaultSelectorClick: () -> Unit, + filterBottomSheetState: FilterBottomSheetState, +) { + val scope = rememberCoroutineScope() + SearchBarDefaults.InputField( + textFieldState = searchTextFieldState, + searchBarState = searchBarState, + onSearch = { + onSubmitQuery() + scope.launch { searchBarState.animateToCollapsed() } + }, + enabled = false, // TODO: remove when b/464761441 is fixed + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + AnimatedContent( + targetState = !uiState.hasSearchQuery && searchBarState.targetValue == SearchBarValue.Collapsed + ) { + when (it) { + true -> Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + + false -> IconButton( + onClick = { + onClearQuery() + scope.launch { searchBarState.animateToCollapsed() } + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowBack, + contentDescription = null + ) + } + } + } + }, + trailingIcon = { + Row { + IconButton(onClick = onShowFilterClick) { + BadgedBox( + badge = { + if (!filterBottomSheetState.isDefault) Badge() + } + ) { + Icon( + imageVector = Icons.Default.FilterList, + contentDescription = stringResource(R.string.filter), + ) + } + } + + val hasVaults by remember(uiState.vaults) { + derivedStateOf { uiState.vaults.isNotEmpty() } + } + + if (hasVaults) + uiState.selectedVaultIcon()?.let { icon -> + IconButton( + onClick = onVaultSelectorClick + ) { + Icon( + painter = icon, + contentDescription = stringResource(R.string.filter), + ) + } + } + } + }, + placeholder = { + Text(text = stringResource(R.string.search_your_vault)) + } + ) +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt index 24fbbfdd..d0c89155 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/mapper/FilterMapper.kt @@ -53,7 +53,7 @@ internal fun List.toAvailableFilterOptions( val itemTypes = fastMapTo(mutableSetOf()) { it.itemType } val hasPasswordItems = VaultItemType.Password in itemTypes - val itemIds = if (hasPasswordItems) fastMapTo(mutableSetOf()) { it.vaultItemId } + val itemIds = if (hasPasswordItems) fastMapTo(mutableSetOf()) { it.id } else emptySet() val scores = if (hasPasswordItems) diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt index b8fac1ad..8e47add1 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt @@ -1,12 +1,19 @@ package de.davis.keygo.feature.list_screen.presentation.model +import androidx.compose.runtime.Stable import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.domain.model.VaultMetadata import de.davis.keygo.core.item.domain.model.lite.LiteItem -data class ListItemState( +@Stable +internal data class ListItemState( val items: List = emptyList(), val searchResults: List = emptyList(), val hasSearchQuery: Boolean = false, val selectedItemIds: Set = emptySet(), val highlightedId: ItemId? = null, -) \ No newline at end of file + val isVaultFlowVisible: Boolean = false, + val vaults: List = emptyList(), + val vaultContext: VaultContext = VaultContext.NoSpecific, +) diff --git a/feature/list_screen/src/main/res/values/strings.xml b/feature/list_screen/src/main/res/values/strings.xml index 2e278ab8..0d92291b 100644 --- a/feature/list_screen/src/main/res/values/strings.xml +++ b/feature/list_screen/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ Search your Vault - + 1 item deleted %d items deleted @@ -23,4 +23,4 @@ Labels Only pinned Items - \ No newline at end of file + diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt index ea5ca16d..bae2b1d7 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/FilterUseCaseTest.kt @@ -1,11 +1,13 @@ package de.davis.keygo.feature.list_screen.domain.usecase import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.model.Password import de.davis.keygo.core.item.domain.model.lite.LiteItem import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.feature.list_screen.domain.model.FilterState import de.davis.keygo.feature.list_screen.domain.model.SortDirection +import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -19,7 +21,7 @@ class FilterUseCaseTest { data class TestLiteItem( override val name: String, - override val vaultItemId: ItemId = 0, + override val id: ItemId = UUID.nameUUIDFromBytes(name.toByteArray()), override val itemType: VaultItemType = VaultItemType.Password, override val pinned: Boolean = false, ) : LiteItem @@ -230,8 +232,8 @@ class FilterUseCaseTest { // Filter by item type private val typedItems = listOf( - TestLiteItem(name = "Login A", vaultItemId = 1, itemType = VaultItemType.Password), - TestLiteItem(name = "Login B", vaultItemId = 2, itemType = VaultItemType.Password), + TestLiteItem(name = "Login A", id = newItemId(), itemType = VaultItemType.Password), + TestLiteItem(name = "Login B", id = newItemId(), itemType = VaultItemType.Password), ) private val noScores: Map = emptyMap() @@ -253,16 +255,16 @@ class FilterUseCaseTest { // Filter by score private val scoredItems = listOf( - TestLiteItem(name = "Excellent PW", vaultItemId = 1), - TestLiteItem(name = "No Score", vaultItemId = 2), - TestLiteItem(name = "Strong PW", vaultItemId = 3), - TestLiteItem(name = "Weak PW", vaultItemId = 4), + TestLiteItem(name = "Excellent PW", id = newItemId()), + TestLiteItem(name = "No Score", id = newItemId()), + TestLiteItem(name = "Strong PW", id = newItemId()), + TestLiteItem(name = "Weak PW", id = newItemId()), ) private val scores = mapOf( - 1L to Password.Score.Excellent, - 3L to Password.Score.Strong, - 4L to Password.Score.Weak, + scoredItems[0].id to Password.Score.Excellent, + scoredItems[2].id to Password.Score.Strong, + scoredItems[3].id to Password.Score.Weak, ) @Test @@ -279,7 +281,7 @@ class FilterUseCaseTest { val state = FilterState(selectedScores = setOf(Password.Score.Weak)) val result = useCase(state, scoredItems, scores) - val expected = scoredItems.filter { scores[it.vaultItemId] == Password.Score.Weak } + val expected = scoredItems.filter { scores[it.id] == Password.Score.Weak } assertEquals(expected, result) } @@ -291,8 +293,8 @@ class FilterUseCaseTest { val result = useCase(state, scoredItems, scores) val expected = scoredItems.filter { - scores[it.vaultItemId] == Password.Score.Weak || - scores[it.vaultItemId] == Password.Score.Excellent + scores[it.id] == Password.Score.Weak || + scores[it.id] == Password.Score.Excellent } assertEquals(expected.size, result.size) @@ -315,7 +317,7 @@ class FilterUseCaseTest { ) val result = useCase(state, scoredItems, scores) assertTrue(result.all { it.itemType == VaultItemType.Password }) - assertTrue(result.all { scores[it.vaultItemId] == Password.Score.Excellent }) + assertTrue(result.all { scores[it.id] == Password.Score.Excellent }) } @Test @@ -334,10 +336,10 @@ class FilterUseCaseTest { // Pinned filter & sorting private val mixedPinnedItems = listOf( - TestLiteItem(name = "Charlie", vaultItemId = 1, pinned = false), - TestLiteItem(name = "Alpha", vaultItemId = 2, pinned = true), - TestLiteItem(name = "Bravo", vaultItemId = 3, pinned = true), - TestLiteItem(name = "Delta", vaultItemId = 4, pinned = false), + TestLiteItem(name = "Charlie", id = newItemId(), pinned = false), + TestLiteItem(name = "Alpha", id = newItemId(), pinned = true), + TestLiteItem(name = "Bravo", id = newItemId(), pinned = true), + TestLiteItem(name = "Delta", id = newItemId(), pinned = false), ) @Test diff --git a/feature/vault/build.gradle.kts b/feature/vault/build.gradle.kts new file mode 100644 index 00000000..a46bcf06 --- /dev/null +++ b/feature/vault/build.gradle.kts @@ -0,0 +1,75 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.koin.compiler) +} + +android { + namespace = "de.davis.keygo.feature.vault" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xcontext-parameters") + jvmTarget = JvmTarget.JVM_17 + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + + implementation(projects.core.item) + implementation(projects.core.ui) + implementation(projects.core.util) + implementation(projects.core.security) + implementation(projects.rust) + + // Koin DI + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.androidx.compose) + implementation(libs.koin.annotations) + + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.io.mockk) + testImplementation(testFixtures(projects.core.item)) + testImplementation(testFixtures(projects.core.security)) + testImplementation(testFixtures(projects.rust)) + + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} diff --git a/feature/vault/consumer-rules.pro b/feature/vault/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/vault/proguard-rules.pro b/feature/vault/proguard-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/vault/src/main/AndroidManifest.xml b/feature/vault/src/main/AndroidManifest.xml new file mode 100644 index 00000000..8072ee00 --- /dev/null +++ b/feature/vault/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/di/FeatureVaultModule.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/di/FeatureVaultModule.kt new file mode 100644 index 00000000..9b5da39c --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/di/FeatureVaultModule.kt @@ -0,0 +1,10 @@ +package de.davis.keygo.feature.vault.di + +import org.koin.core.annotation.ComponentScan +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module + +@Module +@Configuration +@ComponentScan("de.davis.keygo.feature.vault") +object FeatureVaultModule diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/MoveItemsError.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/MoveItemsError.kt new file mode 100644 index 00000000..69e3a9bf --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/MoveItemsError.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.feature.vault.domain.model + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId + +sealed interface MoveItemsError { + data class VaultNotFound(val vaultId: VaultId) : MoveItemsError + + /** A single item failed to re-wrap; no DB writes happened, source vault is untouched. */ + data class ItemMoveFailed(val itemId: ItemId, val cause: Throwable) : MoveItemsError + + /** The atomic bulk write failed; the transaction rolled back and no items were moved. */ + data class PersistFailed(val cause: Throwable) : MoveItemsError +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/MoveItemsProgress.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/MoveItemsProgress.kt new file mode 100644 index 00000000..54a7e00b --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/MoveItemsProgress.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.vault.domain.model + +data class MoveItemsProgress( + val movedCount: Int, + val total: Int, +) { + val fraction: Float = if (total <= 0) 1f else movedCount.toFloat() / total +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/VaultCreationError.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/VaultCreationError.kt new file mode 100644 index 00000000..9e1b6689 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/VaultCreationError.kt @@ -0,0 +1,6 @@ +package de.davis.keygo.feature.vault.domain.model + +sealed interface VaultCreationError { + object WrapFailed : VaultCreationError + object BlankName : VaultCreationError +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/VaultsAndSelection.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/VaultsAndSelection.kt new file mode 100644 index 00000000..beb61be2 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/model/VaultsAndSelection.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.vault.domain.model + +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.domain.model.VaultMetadata + +data class VaultsAndSelection( + val vaults: List, + val selection: VaultContext, +) diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCase.kt new file mode 100644 index 00000000..cdde9f2c --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCase.kt @@ -0,0 +1,55 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.security.domain.Session +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.feature.vault.domain.model.VaultCreationError +import de.davis.keygo.rust.vault.VaultManager +import de.davis.keygo.rust.wrap.KeyWrapper +import de.davis.keygo.rust.wrap.wrapVaultKeyWithResult +import org.koin.core.annotation.Single + +/** + * This use case creates a new vault. This new vault is being used to update + * [de.davis.keygo.core.item.domain.model.VaultContext] and last interacted vault. + */ +@Single +class CreateVaultUseCase( + private val vaultRepository: VaultRepository, + private val vaultContextRepository: VaultContextRepository, + private val vaultManager: VaultManager, + private val keyWrapper: KeyWrapper, + private val session: Session +) { + + suspend operator fun invoke( + name: String, + icon: Vault.Icon, + ): Result { + if (name.isBlank()) return Result.Failure(VaultCreationError.BlankName) + + val vaultId = newVaultId() + + val vaultKey = vaultManager.createNewVaultKey() + val wrappedVaultKey = + keyWrapper.wrapVaultKeyWithResult(session.dek.key.encoded, vaultKey, vaultId) + .getOrNull() ?: return Result.Failure(VaultCreationError.WrapFailed) + + val vault = Vault( + id = vaultId, + name = name.trim(), + icon = icon, + wrappedVaultKey = wrappedVaultKey.ciphertext, + vaultKeyNonce = wrappedVaultKey.nonce, + ) + + vaultRepository.createVault(vault) + vaultContextRepository.setContextAndLastInteracted(vaultId) + return Result.Success(vaultId) + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/DeleteVaultUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/DeleteVaultUseCase.kt new file mode 100644 index 00000000..6ccb7898 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/DeleteVaultUseCase.kt @@ -0,0 +1,30 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.domain.model.getIdOrNull +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import org.koin.core.annotation.Single + +@Single +class DeleteVaultUseCase( + private val vaultRepository: VaultRepository, + private val vaultContextRepository: VaultContextRepository, +) { + + suspend operator fun invoke(vaultId: VaultId) { + val record = vaultContextRepository.getVaultContextRecord() + + if (record.context.getIdOrNull() == vaultId) + vaultContextRepository.setVaultContext(VaultContext.NoSpecific) + + if (record.lastInteractedVaultId == vaultId) { + val replacement = vaultRepository.getLastCreatedVaultId(vaultId) + if (replacement != null) vaultContextRepository.setLastInteractedVault(replacement) + else vaultContextRepository.clearLastInteractedVault() + } + + vaultRepository.deleteVault(vaultId) + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/EditVaultUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/EditVaultUseCase.kt new file mode 100644 index 00000000..8a6755c3 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/EditVaultUseCase.kt @@ -0,0 +1,33 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultUpdater +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.util.Result +import de.davis.keygo.feature.vault.domain.model.VaultCreationError +import org.koin.core.annotation.Single + +@Single +class EditVaultUseCase( + private val vaultRepository: VaultRepository +) { + + suspend operator fun invoke( + vaultId: VaultId, + name: String, + icon: Vault.Icon + ): Result { + if (name.isBlank()) return Result.Failure(VaultCreationError.BlankName) + + vaultRepository.updateVault( + VaultUpdater( + id = vaultId, + name = name.trim(), + icon = icon, + ) + ) + + return Result.Success(Unit) + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCase.kt new file mode 100644 index 00000000..17e87d75 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCase.kt @@ -0,0 +1,77 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.MovableItem +import de.davis.keygo.core.item.domain.repository.ItemRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.util.Result +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.feature.vault.domain.model.MoveItemsError +import de.davis.keygo.feature.vault.domain.model.MoveItemsProgress +import de.davisalessandro.keygo.rust.ItemAad +import org.koin.core.annotation.Single + +@Single +class MoveItemsToVaultUseCase( + private val cryptographicScopeProvider: CryptographicScopeProvider, + private val itemRepository: ItemRepository, + private val vaultRepository: VaultRepository, +) { + + suspend operator fun invoke( + srcVaultId: VaultId, + dstVaultId: VaultId, + onProgress: (MoveItemsProgress) -> Unit = {}, + ): Result { + if (srcVaultId == dstVaultId) return Result.Success(Unit) + + val srcVault = WrappedVaultKeyInformation( + wrappedVaultKey = vaultRepository.getKeyInformation(srcVaultId) + ?: return Result.Failure(MoveItemsError.VaultNotFound(srcVaultId)), + vaultId = srcVaultId, + ) + val dstVault = WrappedVaultKeyInformation( + wrappedVaultKey = vaultRepository.getKeyInformation(dstVaultId) + ?: return Result.Failure(MoveItemsError.VaultNotFound(dstVaultId)), + vaultId = dstVaultId, + ) + + val items = itemRepository.getMovableItemsByVault(srcVaultId) + val total = items.size + onProgress(MoveItemsProgress(movedCount = 0, total = total)) + + // Phase 1: re-wrap every item key under the destination vault. Done before any DB write + // so a per-item rewrap failure aborts cleanly with the source vault untouched. + val rewrapped = ArrayList(total) + items.forEachIndexed { index, item -> + val newKey = when ( + val r = cryptographicScopeProvider.rewrapItemKey( + sourceVault = srcVault, + sourceItem = WrappedItemKeyInformation( + itemAad = ItemAad(itemId = item.id, vaultId = srcVaultId), + wrappedItemKey = item.keyInformation, + ), + destinationVault = dstVault, + ) + ) { + is Result.Success -> r.success + is Result.Failure -> return Result.Failure( + MoveItemsError.ItemMoveFailed(item.id, r.error), + ) + } + rewrapped += MovableItem(id = item.id, keyInformation = newKey) + onProgress(MoveItemsProgress(movedCount = index + 1, total = total)) + } + + // Phase 2: commit all moves in one SQLite transaction. On failure, every row rolls + // back — items remain in the source vault, decryptable under the source vault key. + itemRepository.moveItemsToVault(rewrapped, dstVaultId).onFailure { + return Result.Failure(MoveItemsError.PersistFailed(it)) + } + + return Result.Success(Unit) + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt new file mode 100644 index 00000000..9954fe30 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCase.kt @@ -0,0 +1,21 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import de.davis.keygo.core.item.domain.repository.VaultRepository +import de.davis.keygo.feature.vault.domain.model.VaultsAndSelection +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import org.koin.core.annotation.Single + +@Single +class ObserveVaultsAndSelectionUseCase( + private val vaultRepository: VaultRepository, + private val vaultContextRepository: VaultContextRepository, +) { + operator fun invoke(): Flow = combine( + vaultRepository.observeAllVaultMetadata(), + vaultContextRepository.observeVaultContext(), + ) { vaults, context -> + VaultsAndSelection(vaults = vaults.sortedBy { it.name }, selection = context) + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/SetVaultContextUseCase.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/SetVaultContextUseCase.kt new file mode 100644 index 00000000..a36878f1 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/domain/usecase/SetVaultContextUseCase.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.domain.repository.VaultContextRepository +import org.koin.core.annotation.Single + +/** + * This use case sets the vault context and also updates last interacted Vault. + */ +@Single +class SetVaultContextUseCase( + private val vaultContextRepository: VaultContextRepository +) { + + /** + * Updates the vault context. Also updates last interacted vault when [context] is of + * type [VaultContext.ById]. + */ + suspend operator fun invoke(context: VaultContext) { + vaultContextRepository.setVaultContext(context) + + if (context is VaultContext.ById) + vaultContextRepository.setLastInteractedVault(context.vaultId) + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/VaultFlow.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/VaultFlow.kt new file mode 100644 index 00000000..3a84a0ef --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/VaultFlow.kt @@ -0,0 +1,56 @@ +package de.davis.keygo.feature.vault.presentation + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.davis.keygo.core.util.presentation.ObserveAsEvents +import de.davis.keygo.feature.vault.presentation.components.MoveVaultDialog +import de.davis.keygo.feature.vault.presentation.components.VaultCreationDialog +import de.davis.keygo.feature.vault.presentation.components.VaultDeletionDialog +import de.davis.keygo.feature.vault.presentation.components.VaultSelectionSheet +import de.davis.keygo.feature.vault.presentation.model.VaultState +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VaultFlow(onDismiss: () -> Unit) { + val vm = koinViewModel() + + ObserveAsEvents(vm.dismissEvents) { onDismiss() } + + val vaultState by vm.vaultState.collectAsStateWithLifecycle() + + when (val state = vaultState) { + is VaultState.Delete -> VaultDeletionDialog( + vaultState = state, + onConfirmDeletion = vm::onDeleteVault, + onDismissRequest = vm::dismiss, + ) + + is VaultState.Select -> VaultSelectionSheet( + vaultState = state, + onDismiss = vm::dismiss, + onVaultContextSelect = vm::onVaultContextSelected, + onCreateVaultRequest = vm::onCreateVaultRequest, + onEditRequest = vm::onEditVaultRequest, + onDeleteRequest = vm::onDeleteRequest, + onMoveTo = vm::onMoveTo, + ) + + is VaultState.CreateOrUpdate -> VaultCreationDialog( + vaultState = state, + onDismissRequest = vm::dismiss, + onCreateOrEdit = vm::onCreateOrEditVault, + onIconClick = vm::onVaultIconClick, + ) + + is VaultState.Move -> MoveVaultDialog( + vaultState = state, + onDismissRequest = vm::dismiss, + onDstSelected = vm::onMoveDstSelected, + onConfirm = vm::onConfirmMove, + onDeleteVaultStateChange = vm::onMoveDeleteVaultStateChange, + ) + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/VaultFlowViewModel.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/VaultFlowViewModel.kt new file mode 100644 index 00000000..329317d3 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/VaultFlowViewModel.kt @@ -0,0 +1,229 @@ +package de.davis.keygo.feature.vault.presentation + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.core.util.onFailure +import de.davis.keygo.core.util.onSuccess +import de.davis.keygo.feature.vault.domain.model.MoveItemsProgress +import de.davis.keygo.feature.vault.domain.usecase.CreateVaultUseCase +import de.davis.keygo.feature.vault.domain.usecase.DeleteVaultUseCase +import de.davis.keygo.feature.vault.domain.usecase.EditVaultUseCase +import de.davis.keygo.feature.vault.domain.usecase.MoveItemsToVaultUseCase +import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase +import de.davis.keygo.feature.vault.domain.usecase.SetVaultContextUseCase +import de.davis.keygo.feature.vault.presentation.model.VaultDeletionError +import de.davis.keygo.feature.vault.presentation.model.VaultState +import de.davis.keygo.feature.vault.presentation.model.VaultStateSwitcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.koin.core.annotation.KoinViewModel + +@KoinViewModel +internal class VaultFlowViewModel( + private val setVaultContextUseCase: SetVaultContextUseCase, + private val createVault: CreateVaultUseCase, + private val updateVault: EditVaultUseCase, + private val deleteVault: DeleteVaultUseCase, + private val moveItemsToVault: MoveItemsToVaultUseCase, + observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase, +) : ViewModel() { + + private val vaultsAndSelection = observeVaultsAndSelection() + + private val vaultStateSwitcher = + MutableStateFlow(VaultStateSwitcher.Selection) + private val deleteVaultState = MutableStateFlow(null) + private val createVaultState = MutableStateFlow(VaultState.Create()) + private val editVaultState = MutableStateFlow(null) + private val moveDstSelection = MutableStateFlow(null) + private val moveDeleteSrc = MutableStateFlow(false) + private val moveProgress = MutableStateFlow(null) + + private val _dismissEvents = Channel(Channel.CONFLATED) + val dismissEvents = _dismissEvents.receiveAsFlow() + + private val vaultSelectionState = vaultsAndSelection.map { + VaultState.Select(vaults = it.vaults, vaultContext = it.selection) + }.distinctUntilChanged() + + @OptIn(ExperimentalCoroutinesApi::class) + val vaultState = vaultStateSwitcher.flatMapLatest { switcher -> + when (switcher) { + VaultStateSwitcher.Delete -> deleteVaultState.filterNotNull() + VaultStateSwitcher.Selection -> vaultSelectionState + VaultStateSwitcher.Create -> createVaultState + VaultStateSwitcher.Edit -> editVaultState.filterNotNull() + + is VaultStateSwitcher.Move -> combine( + vaultsAndSelection, + moveDstSelection, + moveDeleteSrc, + moveProgress, + ) { vs, dstId, delete, progress -> + val src = vs.vaults.firstOrNull { it.vaultId == switcher.srcVaultId } + ?: return@combine null + val dstVaults = vs.vaults.filter { it.vaultId != switcher.srcVaultId } + VaultState.Move( + srcVault = src, + dstVaults = dstVaults, + delete = delete, + selectedDstVaultId = dstId ?: dstVaults.firstOrNull()?.vaultId, + progress = progress, + ) + }.filterNotNull() + } + }.distinctUntilChanged() + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = VaultState.Select(), + ) + + fun onVaultContextSelected(context: VaultContext) { + viewModelScope.launch { setVaultContextUseCase(context) } + } + + fun onCreateVaultRequest() { + vaultStateSwitcher.update { VaultStateSwitcher.Create } + } + + fun onCreateOrEditVault() { + viewModelScope.launch { + when (vaultStateSwitcher.value) { + VaultStateSwitcher.Delete, + VaultStateSwitcher.Selection, + is VaultStateSwitcher.Move -> null + + VaultStateSwitcher.Create -> { + val state = createVaultState.value + createVault( + name = state.nameTextFieldState.text.toString(), + icon = state.icon, + ).onFailure { error -> + createVaultState.update { state.copy(error = error) } + } + } + + VaultStateSwitcher.Edit -> + editVaultState.value?.let { state -> + updateVault( + vaultId = state.vaultId, + name = state.nameTextFieldState.text.toString(), + icon = state.icon, + ).onFailure { error -> + editVaultState.update { state.copy(error = error) } + } + } + }?.onSuccess { + dismiss() + } + } + } + + fun onVaultIconClick(icon: Vault.Icon) { + when (vaultStateSwitcher.value) { + VaultStateSwitcher.Delete, + VaultStateSwitcher.Selection, + is VaultStateSwitcher.Move -> { + } + + VaultStateSwitcher.Create -> + createVaultState.update { it.copy(icon = icon) } + + VaultStateSwitcher.Edit -> + editVaultState.value?.let { state -> + editVaultState.update { state.copy(icon = icon) } + } + } + } + + fun onEditVaultRequest(vaultMetadata: VaultMetadata) { + editVaultState.update { + VaultState.Edit( + vaultId = vaultMetadata.vaultId, + nameTextFieldState = TextFieldState(vaultMetadata.name), + icon = vaultMetadata.icon, + ) + } + vaultStateSwitcher.update { VaultStateSwitcher.Edit } + } + + fun onDeleteRequest(vaultMetadata: VaultMetadata) { + deleteVaultState.update { + VaultState.Delete( + vaultMetadata = vaultMetadata + ) + } + vaultStateSwitcher.update { VaultStateSwitcher.Delete } + } + + fun onDeleteVault(enteredName: String) { + when (vaultStateSwitcher.value) { + VaultStateSwitcher.Delete -> { + deleteVaultState.value?.let { state -> + if (state.vaultMetadata.name != enteredName) + return@let deleteVaultState.update { state.copy(error = VaultDeletionError.NameDoesNotMatch) } + + viewModelScope.launch { deleteVault(state.vaultMetadata.vaultId) } + dismiss(force = true) + } + } + + else -> {} + } + } + + fun onMoveTo(vaultId: VaultId) { + moveDstSelection.update { null } + moveProgress.update { null } + vaultStateSwitcher.update { VaultStateSwitcher.Move(vaultId) } + } + + fun onMoveDstSelected(vaultId: VaultId) { + moveDstSelection.update { vaultId } + } + + fun onConfirmMove() { + val switcher = vaultStateSwitcher.value as? VaultStateSwitcher.Move ?: return + if (moveProgress.value != null) return + val moveState = vaultState.value as? VaultState.Move ?: return + val dstVaultId = moveState.selectedDstVaultId ?: return + viewModelScope.launch { + moveItemsToVault(switcher.srcVaultId, dstVaultId) { progress -> + moveProgress.update { progress } + }.onSuccess { + if (moveState.delete) + viewModelScope.launch { deleteVault(switcher.srcVaultId) } + } + dismiss(force = true) + } + } + + fun dismiss(force: Boolean = false) { + if (!force && moveProgress.value != null) return + vaultStateSwitcher.update { VaultStateSwitcher.Selection } + createVaultState.update { VaultState.Create() } + moveProgress.update { null } + _dismissEvents.trySend(Unit) + } + + fun onMoveDeleteVaultStateChange(checked: Boolean) { + moveDeleteSrc.update { checked } + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/VaultIcons.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/VaultIcons.kt new file mode 100644 index 00000000..e248fd87 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/VaultIcons.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.vault.presentation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource + +val AllVaultsIcon: Painter + @Composable + get() = painterResource(de.davis.keygo.core.ui.R.drawable.ic_launcher_monochrome) diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/MoveVaultDialog.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/MoveVaultDialog.kt new file mode 100644 index 00000000..616b988f --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/MoveVaultDialog.kt @@ -0,0 +1,314 @@ +package de.davis.keygo.feature.vault.presentation.components + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearWavyProgressIndicator +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.core.item.presentation.toImageVector +import de.davis.keygo.core.ui.components.KeyGoSwitch +import de.davis.keygo.core.ui.theme.KeyGoTheme +import de.davis.keygo.feature.vault.R +import de.davis.keygo.feature.vault.domain.model.MoveItemsProgress +import de.davis.keygo.feature.vault.presentation.model.VaultState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MoveVaultDialog( + vaultState: VaultState.Move, + onDismissRequest: () -> Unit, + onDstSelected: (VaultId) -> Unit, + onConfirm: () -> Unit, + onDeleteVaultStateChange: (Boolean) -> Unit, +) { + val isMoving = vaultState.isMoving + AlertDialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties( + dismissOnBackPress = !isMoving, + dismissOnClickOutside = !isMoving, + ), + title = { + Text( + text = stringResource( + if (isMoving) R.string.moving_items else R.string.move_to, + ), + ) + }, + confirmButton = { + if (!isMoving) + Button( + onClick = onConfirm, + enabled = vaultState.selectedDstVault != null, + ) { + Text(text = stringResource(R.string.move)) + } + }, + modifier = Modifier.fillMaxWidth(), + dismissButton = { + if (!isMoving) + TextButton(onClick = onDismissRequest) { + Text(text = stringResource(R.string.cancel)) + } + }, + text = { + if (vaultState.progress != null) + MoveVaultProgressContent(progress = vaultState.progress) + else + MoveVaultDialogContent( + vaultState = vaultState, + onDstSelected = onDstSelected, + onDeleteVaultStateChange = onDeleteVaultStateChange, + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun MoveVaultProgressContent(progress: MoveItemsProgress) { + val animatedFraction by animateFloatAsState( + targetValue = progress.fraction, + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + label = "moveProgress", + ) + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + LinearWavyProgressIndicator( + progress = { animatedFraction }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = stringResource( + R.string.move_progress_count, + progress.movedCount, + progress.total, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun MoveVaultDialogContent( + vaultState: VaultState.Move, + onDstSelected: (VaultId) -> Unit, + onDeleteVaultStateChange: (Boolean) -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth(), + ) { + SrcVaultRow(srcVault = vaultState.srcVault) + + Icon( + imageVector = Icons.Default.ArrowDownward, + contentDescription = null, + modifier = Modifier + .padding(vertical = 4.dp) + .size(32.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + DstVaultDropdown( + dstVaults = vaultState.dstVaults, + selectedDstVault = vaultState.selectedDstVault, + onDstSelected = onDstSelected, + ) + + KeyGoSwitch( + checked = vaultState.delete, + onCheckedChange = onDeleteVaultStateChange, + colors = ListItemDefaults.colors( + containerColor = AlertDialogDefaults.containerColor + ) + ) { + Text(text = stringResource(R.string.delete_vault_after_move)) + } + } +} + +@Composable +private fun SrcVaultRow(srcVault: VaultMetadata) { + ListItem( + leadingContent = { + Icon( + imageVector = srcVault.icon.toImageVector(), + contentDescription = null, + ) + }, + overlineContent = { Text(text = stringResource(R.string.source_vault)) }, + headlineContent = { Text(text = srcVault.name) }, + colors = ListItemDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ), + modifier = Modifier.fillMaxWidth(), + ) +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun DstVaultDropdown( + dstVaults: List, + selectedDstVault: VaultMetadata?, + onDstSelected: (VaultId) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = it }, + ) { + OutlinedTextField( + value = selectedDstVault?.name.orEmpty(), + onValueChange = {}, + readOnly = true, + singleLine = true, + label = { Text(text = stringResource(R.string.destination_vault)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + leadingIcon = selectedDstVault?.let { vault -> + { + Icon( + imageVector = vault.icon.toImageVector(), + modifier = Modifier.size(MenuDefaults.LeadingIconSize), + contentDescription = null, + ) + } + }, + colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(), + modifier = Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) { + val optionCount = dstVaults.size + dstVaults.forEachIndexed { index, vault -> + DropdownMenuItem( + shapes = MenuDefaults.itemShape(index, optionCount), + colors = MenuDefaults.selectableItemColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ), + selected = vault.vaultId == selectedDstVault?.vaultId, + text = { + Text( + text = vault.name, + style = MaterialTheme.typography.bodyLarge, + ) + }, + onClick = { + onDstSelected(vault.vaultId) + expanded = false + }, + selectedLeadingIcon = { + Icon( + imageVector = Icons.Filled.Check, + modifier = Modifier.size(MenuDefaults.LeadingIconSize), + contentDescription = null, + ) + }, + leadingIcon = { + Icon( + imageVector = vault.icon.toImageVector(), + modifier = Modifier.size(MenuDefaults.LeadingIconSize), + contentDescription = null, + ) + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} + +@Preview +@Composable +private fun MoveVaultDialogContentPreview() { + val srcId = remember { newVaultId() } + val dst = remember { + listOf( + VaultMetadata( + vaultId = newVaultId(), + name = "Personal", + icon = Vault.Icon.Person, + count = 12, + ), + VaultMetadata( + vaultId = newVaultId(), + name = "Work", + icon = Vault.Icon.Work, + count = 4, + ), + ) + } + KeyGoTheme { + Surface { + Box(modifier = Modifier.padding(16.dp)) { + MoveVaultDialogContent( + vaultState = VaultState.Move( + srcVault = VaultMetadata( + vaultId = srcId, + name = "Shopping", + icon = Vault.Icon.ShoppingCart, + count = 7, + ), + dstVaults = dst, + selectedDstVaultId = dst.first().vaultId, + ), + onDstSelected = {}, + onDeleteVaultStateChange = {} + ) + } + } + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultCreationDialog.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultCreationDialog.kt new file mode 100644 index 00000000..64366b69 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultCreationDialog.kt @@ -0,0 +1,163 @@ +package de.davis.keygo.feature.vault.presentation.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledTonalIconToggleButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.presentation.toImageVector +import de.davis.keygo.core.ui.theme.KeyGoTheme +import de.davis.keygo.feature.vault.R +import de.davis.keygo.feature.vault.domain.model.VaultCreationError +import de.davis.keygo.feature.vault.presentation.model.VaultState + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VaultCreationDialog( + vaultState: VaultState.CreateOrUpdate, + onDismissRequest: () -> Unit, + onCreateOrEdit: () -> Unit, + onIconClick: (Vault.Icon) -> Unit, +) { + val isCreate = vaultState is VaultState.Create + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + Button( + onClick = onCreateOrEdit + ) { + Text(text = stringResource(if (isCreate) R.string.create else R.string.edit)) + } + }, + title = { + Text(text = stringResource(if (isCreate) R.string.create_new_vault else R.string.edit)) + }, + modifier = Modifier.fillMaxWidth(), + text = { + VaultCreationDialogContent( + vaultState = vaultState, + onIconClick = onIconClick + ) + }, + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun VaultCreationDialogContent( + vaultState: VaultState.CreateOrUpdate, + onIconClick: (Vault.Icon) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(bottom = 16.dp) + ) { + OutlinedTextField( + state = vaultState.nameTextFieldState, + modifier = Modifier + .focusRequester(focusRequester) + .fillMaxWidth(), + label = { Text(text = stringResource(R.string.vault_name)) }, + placeholder = { Text(text = stringResource(R.string.vault_name_placeholder)) }, + leadingIcon = { + AnimatedContent(vaultState.icon) { icon -> + Icon( + imageVector = icon.toImageVector(), + contentDescription = null + ) + } + }, + isError = vaultState.error != null, + supportingText = vaultState.error?.let { error -> + { + Text(text = error.message()) + } + } + ) + + Text( + text = stringResource(R.string.icon), + modifier = Modifier.padding(top = 2.dp), + style = MaterialTheme.typography.labelMedium, + ) + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Vault.Icon.entries.forEach { icon -> + FilledTonalIconToggleButton( + checked = vaultState.icon == icon, + onCheckedChange = { + onIconClick(icon) + focusManager.clearFocus() + }, + modifier = Modifier + .minimumInteractiveComponentSize() + .size(IconButtonDefaults.mediumContainerSize()), + shapes = IconButtonDefaults.toggleableShapes(), + ) { + Icon( + imageVector = icon.toImageVector(), + contentDescription = null + ) + } + } + } + } +} + +@Composable +private fun VaultCreationError.message() = when (this) { + VaultCreationError.BlankName -> stringResource(R.string.vault_name_blank_error) + VaultCreationError.WrapFailed -> stringResource(R.string.vault_creation_wrap_failed) +} + +@Preview +@Composable +private fun VaultCreationDialogContentPreview() { + KeyGoTheme { + Surface { + VaultCreationDialogContent( + vaultState = VaultState.Create( + error = VaultCreationError.BlankName, + ), + onIconClick = {}, + ) + } + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultDeletionDialog.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultDeletionDialog.kt new file mode 100644 index 00000000..a00a2a9d --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultDeletionDialog.kt @@ -0,0 +1,211 @@ +package de.davis.keygo.feature.vault.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AlertDialogDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.core.item.presentation.toImageVector +import de.davis.keygo.core.ui.theme.KeyGoTheme +import de.davis.keygo.feature.vault.R +import de.davis.keygo.feature.vault.presentation.model.VaultDeletionError +import de.davis.keygo.feature.vault.presentation.model.VaultState + +@Composable +fun VaultDeletionDialog( + vaultState: VaultState.Delete, + onConfirmDeletion: (String) -> Unit, + onDismissRequest: () -> Unit +) { + val vaultNameTextFieldState = rememberTextFieldState() + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Text(text = stringResource(R.string.permanently_delete)) + }, + confirmButton = { + Button( + onClick = onDismissRequest + ) { + Text(text = stringResource(R.string.cancel)) + } + }, + icon = { + Icon( + imageVector = Icons.Default.DeleteForever, + contentDescription = null + ) + }, + modifier = Modifier.fillMaxWidth(), + dismissButton = { + TextButton( + onClick = { onConfirmDeletion(vaultNameTextFieldState.text.toString()) } + ) { + Text(text = stringResource(R.string.delete)) + } + }, + text = { + VaultDeletionDialogContent( + vaultState = vaultState, + vaultNameTextFieldState = vaultNameTextFieldState, + ) + }, + ) +} + +private const val ICON_MARKER = "\uE000" // Private Use Area, won't collide with real text +private const val ICON_ID = "vault_icon" + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun VaultDeletionDialogContent( + vaultState: VaultState.Delete, + vaultNameTextFieldState: TextFieldState +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + ListItem( + colors = ListItemDefaults.colors( + containerColor = AlertDialogDefaults.containerColor, + ), + headlineContent = { + Text(text = vaultState.vaultMetadata.name) + }, + supportingContent = { + Text( + text = pluralStringResource( + R.plurals.vault_item_entry_count, + vaultState.vaultMetadata.count, + vaultState.vaultMetadata.count + ) + ) + }, + leadingContent = { + Icon( + imageVector = vaultState.vaultMetadata.icon.toImageVector(), + contentDescription = null, + ) + }, + modifier = Modifier.fillMaxWidth() + ) + + val rawHtml = pluralStringResource( + R.plurals.delete_vault_warning, + vaultState.vaultMetadata.count, + "$ICON_MARKER ${vaultState.vaultMetadata.name}", + vaultState.vaultMetadata.count, + ) + val parsed = AnnotatedString.fromHtml(rawHtml) + + val annotated = remember(parsed) { + val idx = parsed.text.indexOf(ICON_MARKER) + if (idx < 0) parsed + else buildAnnotatedString { + append(parsed.subSequence(0, idx)) + appendInlineContent(ICON_ID, "[icon]") + append(parsed.subSequence(idx + ICON_MARKER.length, parsed.length)) + } + } + + val inlineContent = mapOf( + ICON_ID to InlineTextContent( + Placeholder( + width = LocalTextStyle.current.fontSize, + height = LocalTextStyle.current.fontSize, + placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter, + ), + ) { + Icon( + imageVector = vaultState.vaultMetadata.icon.toImageVector(), + contentDescription = null, + ) + }, + ) + + Text(text = annotated, inlineContent = inlineContent) + + Text( + text = AnnotatedString.fromHtml( + stringResource( + R.string.delete_instruction, + vaultState.vaultMetadata.name + ) + ) + ) + + OutlinedTextField( + state = vaultNameTextFieldState, + label = { + Text(text = stringResource(R.string.vault_name)) + }, + lineLimits = TextFieldLineLimits.SingleLine, + modifier = Modifier.fillMaxWidth(), + isError = vaultState.error != null, + supportingText = vaultState.error?.let { error -> + { + Text(text = error.toText()) + } + } + ) + } +} + +@Composable +private fun VaultDeletionError.toText() = when (this) { + VaultDeletionError.NameDoesNotMatch -> stringResource(R.string.name_does_not_match) +} + +@Preview +@Composable +private fun VaultDeletionDialogContentPreview() { + KeyGoTheme { + Surface( + color = AlertDialogDefaults.containerColor + ) { + VaultDeletionDialogContent( + vaultState = VaultState.Delete( + vaultMetadata = VaultMetadata( + vaultId = newVaultId(), + name = "Test Vault", + icon = Vault.Icon.Default, + count = 3, + ) + ), + vaultNameTextFieldState = rememberTextFieldState() + ) + } + } +} \ No newline at end of file diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultSelectionSheet.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultSelectionSheet.kt new file mode 100644 index 00000000..60bb86e4 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/components/VaultSelectionSheet.kt @@ -0,0 +1,331 @@ +package de.davis.keygo.feature.vault.presentation.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.DriveFileMove +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenuGroup +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenuPopup +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.SheetState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipAnchorPosition +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberTooltipState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.core.item.domain.model.getIdOrNull +import de.davis.keygo.core.item.presentation.toImageVector +import de.davis.keygo.core.ui.theme.KeyGoTheme +import de.davis.keygo.feature.vault.R +import de.davis.keygo.feature.vault.presentation.AllVaultsIcon +import de.davis.keygo.feature.vault.presentation.model.VaultState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VaultSelectionSheet( + vaultState: VaultState.Select, + onDismiss: () -> Unit, + onVaultContextSelect: (VaultContext) -> Unit, + onCreateVaultRequest: () -> Unit, + onEditRequest: (VaultMetadata) -> Unit, + onDeleteRequest: (VaultMetadata) -> Unit, + onMoveTo: (VaultId) -> Unit, + sheetState: SheetState = rememberModalBottomSheetState(), +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + Box( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + VaultSelectionSheetContent( + vaultState = vaultState, + onVaultContextSelect = onVaultContextSelect, + onCreateVaultRequest = onCreateVaultRequest, + onEditRequest = onEditRequest, + onDeleteRequest = onDeleteRequest, + onMoveTo = onMoveTo, + ) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun VaultSelectionSheetContent( + vaultState: VaultState.Select, + onVaultContextSelect: (VaultContext) -> Unit, + onCreateVaultRequest: () -> Unit, + onEditRequest: (VaultMetadata) -> Unit, + onDeleteRequest: (VaultMetadata) -> Unit, + onMoveTo: (VaultId) -> Unit, +) { + val sumAllVaults = vaultState.sumCount + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), + ) { + item(key = "vault_selection_all") { + SegmentedListItem( + selected = vaultState.vaultContext is VaultContext.NoSpecific, + onClick = { onVaultContextSelect(VaultContext.NoSpecific) }, + shapes = ListItemDefaults.segmentedShapes(0, 2), + colors = ListItemDefaults.segmentedColors(), + leadingContent = { + Icon( + painter = AllVaultsIcon, + modifier = Modifier.size(24.dp), + contentDescription = null + ) + }, + supportingContent = { + Text( + text = pluralStringResource( + R.plurals.vault_item_entry_count, + sumAllVaults, + sumAllVaults + ) + ) + } + ) { + Text(text = stringResource(R.string.all_vaults)) + } + } + + item(key = "vault_selection_add_new") { + SegmentedListItem( + onClick = onCreateVaultRequest, + shapes = ListItemDefaults.segmentedShapes(1, 2), + colors = ListItemDefaults.segmentedColors(), + leadingContent = { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null + ) + } + ) { + Text(text = stringResource(R.string.create_new_vault)) + } + } + + val vaultCount = vaultState.vaults.size + itemsIndexed( + items = vaultState.vaults, + key = { _, metadata -> metadata.vaultId } + ) { index, metadata -> + SegmentedListItem( + selected = vaultState.vaultContext.getIdOrNull() == metadata.vaultId, + onClick = { onVaultContextSelect(VaultContext.ById(metadata.vaultId)) }, + shapes = if (vaultCount == 1) ListItemDefaults.shapes(MaterialTheme.shapes.large) + else ListItemDefaults.segmentedShapes( + index = index, + count = vaultCount, + ), + colors = ListItemDefaults.segmentedColors(), + leadingContent = { + Icon( + imageVector = metadata.icon.toImageVector(), + contentDescription = null + ) + }, + supportingContent = { + Text( + text = pluralStringResource( + R.plurals.vault_item_entry_count, + metadata.count, + metadata.count + ) + ) + }, + trailingContent = { + var expanded by rememberSaveable { mutableStateOf(false) } + Box(modifier = Modifier.wrapContentSize(Alignment.TopStart)) { + TooltipBox( + positionProvider = + TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above + ), + tooltip = { PlainTooltip { Text(text = stringResource(R.string.edit)) } }, + state = rememberTooltipState(), + ) { + IconButton(onClick = { expanded = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = stringResource(R.string.edit) + ) + } + } + + DropdownMenuPopup( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuGroup( + shapes = MenuDefaults.groupShape( + 0, + if (vaultState.hasMultipleVaults) 2 else 1 + ) + ) { + val hasItems by remember(metadata.count) { + derivedStateOf { metadata.count > 0 } + } + + DropdownMenuItem( + text = { + Text(text = stringResource(R.string.edit)) + }, + onClick = { + expanded = false + onEditRequest(metadata) + }, + shape = MenuDefaults.itemShape( + 0, + if (vaultState.hasMultipleVaults && hasItems) 2 else 1 + ).shape, + leadingIcon = { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = stringResource(R.string.edit) + ) + } + ) + + if (vaultState.hasMultipleVaults && hasItems) { + DropdownMenuItem( + text = { + Text(text = stringResource(R.string.move_to)) + }, + onClick = { + expanded = false + onMoveTo(metadata.vaultId) + }, + shape = MenuDefaults.itemShape(1, 2).shape, + leadingIcon = { + Icon( + imageVector = Icons.AutoMirrored.Default.DriveFileMove, + contentDescription = stringResource(R.string.move_to) + ) + } + ) + } + } + + if (vaultState.hasMultipleVaults) { + Spacer(modifier = Modifier.height(MenuDefaults.GroupSpacing)) + DropdownMenuGroup( + shapes = MenuDefaults.groupShape(1, 2), + containerColor = MaterialTheme.colorScheme.errorContainer + ) { + DropdownMenuItem( + text = { + Text(text = stringResource(R.string.delete)) + }, + onClick = { + expanded = false + onDeleteRequest(metadata) + }, + shape = MenuDefaults.trailingItemShape, + colors = MenuDefaults.selectableItemColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + leadingIcon = { + Icon( + imageVector = Icons.Default.DeleteForever, + contentDescription = stringResource(R.string.delete) + ) + } + ) + } + } + } + } + } + ) { + Text(text = metadata.name) + } + } + } +} + +@Preview +@Composable +private fun VaultSelectionSheetContentPreview() { + val selectedVaultId = remember { newVaultId() } + KeyGoTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + VaultSelectionSheetContent( + vaultState = VaultState.Select( + vaults = listOf( + VaultMetadata( + vaultId = newVaultId(), + name = "Personal", + icon = Vault.Icon.Default, + count = 42 + ), + VaultMetadata( + vaultId = selectedVaultId, + name = "Work", + icon = Vault.Icon.Work, + count = 1, + ), + ), + vaultContext = VaultContext.ById(selectedVaultId) + ), + onVaultContextSelect = {}, + onCreateVaultRequest = {}, + onEditRequest = {}, + onDeleteRequest = {}, + onMoveTo = {}, + ) + } + } +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/CreateVaultRequest.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/CreateVaultRequest.kt new file mode 100644 index 00000000..c137a88c --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/CreateVaultRequest.kt @@ -0,0 +1,8 @@ +package de.davis.keygo.feature.vault.presentation.model + +import de.davis.keygo.core.item.domain.model.Vault + +data class CreateVaultRequest( + val name: String, + val icon: Vault.Icon +) diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultDeletionError.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultDeletionError.kt new file mode 100644 index 00000000..d7e1dcca --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultDeletionError.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.feature.vault.presentation.model + +sealed interface VaultDeletionError { + data object NameDoesNotMatch : VaultDeletionError +} \ No newline at end of file diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultState.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultState.kt new file mode 100644 index 00000000..cb650302 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultState.kt @@ -0,0 +1,64 @@ +package de.davis.keygo.feature.vault.presentation.model + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.item.domain.model.VaultMetadata +import de.davis.keygo.feature.vault.domain.model.MoveItemsProgress +import de.davis.keygo.feature.vault.domain.model.VaultCreationError + +@Stable +sealed interface VaultState { + + @Stable + data class Delete( + val vaultMetadata: VaultMetadata, + val error: VaultDeletionError? = null, + ) : VaultState + + @Stable + data class Select( + val vaults: List = emptyList(), + val vaultContext: VaultContext = VaultContext.NoSpecific, + ) : VaultState { + val sumCount = vaults.sumOf { it.count } + val hasMultipleVaults = vaults.size > 1 + } + + @Stable + data class Move( + val srcVault: VaultMetadata, + val dstVaults: List, + val delete: Boolean = false, + val selectedDstVaultId: VaultId? = dstVaults.firstOrNull()?.vaultId, + val progress: MoveItemsProgress? = null, + ) : VaultState { + val selectedDstVault: VaultMetadata? = + dstVaults.firstOrNull { it.vaultId == selectedDstVaultId } + val isMoving: Boolean = progress != null + } + + @Stable + sealed interface CreateOrUpdate : VaultState { + val nameTextFieldState: TextFieldState + val icon: Vault.Icon + val error: VaultCreationError? + } + + @Stable + data class Create( + override val nameTextFieldState: TextFieldState = TextFieldState(), + override val icon: Vault.Icon = Vault.Icon.Default, + override val error: VaultCreationError? = null, + ) : CreateOrUpdate + + @Stable + data class Edit( + val vaultId: VaultId, + override val nameTextFieldState: TextFieldState = TextFieldState(), + override val icon: Vault.Icon = Vault.Icon.Default, + override val error: VaultCreationError? = null, + ) : CreateOrUpdate +} diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultStateSettings.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultStateSettings.kt new file mode 100644 index 00000000..4bbe9284 --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultStateSettings.kt @@ -0,0 +1,9 @@ +package de.davis.keygo.feature.vault.presentation.model + +import de.davis.keygo.feature.vault.domain.model.VaultCreationError + +data class VaultStateSettings( + val showSelection: Boolean = false, + val showCreationDialog: Boolean = false, + val error: VaultCreationError? = null, +) diff --git a/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultStateSwitcher.kt b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultStateSwitcher.kt new file mode 100644 index 00000000..4c0f45be --- /dev/null +++ b/feature/vault/src/main/kotlin/de/davis/keygo/feature/vault/presentation/model/VaultStateSwitcher.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.feature.vault.presentation.model + +import de.davis.keygo.core.item.domain.alias.VaultId + +sealed interface VaultStateSwitcher { + data object Delete : VaultStateSwitcher + data object Selection : VaultStateSwitcher + data object Create : VaultStateSwitcher + data object Edit : VaultStateSwitcher + data class Move(val srcVaultId: VaultId) : VaultStateSwitcher +} diff --git a/feature/vault/src/main/res/values/strings.xml b/feature/vault/src/main/res/values/strings.xml new file mode 100644 index 00000000..040184dd --- /dev/null +++ b/feature/vault/src/main/res/values/strings.xml @@ -0,0 +1,35 @@ + + + Create + All Vaults + Vault Name + e.g. Work + Create new Vault + Icon + Vault Name can not be blank + Vault key could not be wrapped + + 1 item + %d items + + Edit + Move to + Delete + Move + Cancel + Destination vault + Source vault + Moving items… + %1$d / %2$d + + Delete vault after move + + Permanently Delete + To confirm and permanently delete, enter \"<b>%s</b>\" below. + + Deleting <b>%1$s</b> will permanently remove the vault and its <b>%2$d item</b>. This action cannot be undone. Consider moving this item to another vault before continuing. + + Deleting <b>%1$s</b> will permanently remove the vault and its <b>%2$d items</b>. This action cannot be undone. Consider moving these items to another vault before continuing. + + The name is incorrect + diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCaseTest.kt new file mode 100644 index 00000000..9b9af177 --- /dev/null +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/CreateVaultUseCaseTest.kt @@ -0,0 +1,147 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultContext +import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.util.getOrNull +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.isSuccess +import de.davis.keygo.feature.vault.domain.model.VaultCreationError +import de.davis.keygo.rust.FakeKeyWrapper +import de.davis.keygo.rust.FakeVaultManager +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class CreateVaultUseCaseTest { + + private val session = FakeSession() + + private val vaultRepository = FakeVaultRepository() + private val vaultContextRepository = FakeVaultContextRepository() + private val vaultManager = FakeVaultManager() + private val keyWrapper = FakeKeyWrapper() + + private val useCase = CreateVaultUseCase( + vaultRepository = vaultRepository, + vaultContextRepository = vaultContextRepository, + vaultManager = vaultManager, + keyWrapper = keyWrapper, + session = session, + ) + + @Test + fun `returns BlankName when name is empty`() = runTest { + val result = useCase(name = "", icon = Vault.Icon.Default) + + assertTrue(result.isFailure()) + assertEquals(VaultCreationError.BlankName, result.error) + } + + @Test + fun `returns BlankName when name is whitespace only`() = runTest { + val result = useCase(name = " ", icon = Vault.Icon.Default) + + assertTrue(result.isFailure()) + assertEquals(VaultCreationError.BlankName, result.error) + } + + @Test + fun `does not persist a vault when name is blank`() = runTest { + useCase(name = "", icon = Vault.Icon.Default) + + assertEquals(emptyList(), vaultRepository.observeVaults().first()) + } + + @Test + fun `does not touch context when name is blank`() = runTest { + useCase(name = "", icon = Vault.Icon.Default) + + assertEquals(VaultContext.NoSpecific, vaultContextRepository.currentContext) + assertEquals(null, vaultContextRepository.currentLastInteracted) + } + + @Test + fun `creates vault on success`() = runTest { + val result = useCase(name = "Personal", icon = Vault.Icon.Default) + + assertTrue(result.isSuccess()) + val stored = vaultRepository.observeVaults().first().single() + assertEquals("Personal", stored.name) + assertEquals(Vault.Icon.Default, stored.icon) + } + + @Test + fun `success returns the new vault id`() = runTest { + val result = useCase(name = "Personal", icon = Vault.Icon.Default) + + val id = assertNotNull(result.getOrNull()) + val stored = vaultRepository.observeVaults().first().single() + assertEquals(stored.id, id) + } + + @Test + fun `success persists wrapped vault key bytes`() = runTest { + val result = useCase(name = "Personal", icon = Vault.Icon.Default) + assertTrue(result.isSuccess()) + + val stored = vaultRepository.observeVaults().first().single() + assertTrue(stored.keyInformation.wrappedKey.isNotEmpty()) + assertTrue(stored.keyInformation.keyNonce.isNotEmpty()) + } + + @Test + fun `success sets context and last interacted to the new vault`() = runTest { + val result = useCase(name = "Personal", icon = Vault.Icon.Default) + val id = assertNotNull(result.getOrNull()) + + assertEquals(VaultContext.ById(id), vaultContextRepository.currentContext) + assertEquals(id, vaultContextRepository.currentLastInteracted) + } + + @Test + fun `success preserves the chosen icon`() = runTest { + val result = useCase(name = "Home", icon = Vault.Icon.Home) + assertTrue(result.isSuccess()) + + val stored = vaultRepository.observeVaults().first().single() + assertEquals(Vault.Icon.Home, stored.icon) + } + + @Test + fun `trims surrounding whitespace from name before persisting`() = runTest { + val result = useCase(name = " My Vault ", icon = Vault.Icon.Default) + assertTrue(result.isSuccess()) + + val stored = vaultRepository.observeVaults().first().single() + assertEquals("My Vault", stored.name) + } + + @Test + fun `creates distinct vault ids and keys across consecutive invocations`() = runTest { + val a = assertNotNull(useCase(name = "A", icon = Vault.Icon.Default).getOrNull()) + val b = assertNotNull(useCase(name = "B", icon = Vault.Icon.Default).getOrNull()) + + assertTrue(a != b) + val all = vaultRepository.observeVaults().first() + assertEquals(2, all.size) + val keysA = all.single { it.id == a }.keyInformation.wrappedKey + val keysB = all.single { it.id == b }.keyInformation.wrappedKey + assertTrue(!keysA.contentEquals(keysB)) + } + + @Test + fun `second create overrides context and last interacted with newer vault`() = runTest { + val first = assertNotNull(useCase(name = "A", icon = Vault.Icon.Default).getOrNull()) + val second = assertNotNull(useCase(name = "B", icon = Vault.Icon.Default).getOrNull()) + + assertTrue(first != second) + assertEquals(VaultContext.ById(second), vaultContextRepository.currentContext) + assertEquals(second, vaultContextRepository.currentLastInteracted) + } +} diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/DeleteVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/DeleteVaultUseCaseTest.kt new file mode 100644 index 00000000..7644edcb --- /dev/null +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/DeleteVaultUseCaseTest.kt @@ -0,0 +1,143 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultContext +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class DeleteVaultUseCaseTest { + + private val vaultA = testVault("Alpha") + private val vaultB = testVault("Bravo") + private val vaultC = testVault("Charlie") + + private val vaultRepository = FakeVaultRepository() + private val vaultContextRepository = FakeVaultContextRepository() + + private val useCase = DeleteVaultUseCase( + vaultRepository = vaultRepository, + vaultContextRepository = vaultContextRepository, + ) + + @Test + fun `removes the deleted vault from the repository`() = runTest { + vaultRepository.seed(vaultA, vaultB) + vaultContextRepository.seedLastInteracted(vaultB.id) + + useCase(vaultA.id) + + val remainingIds = vaultRepository.observeVaults().first().map { it.id } + assertEquals(listOf(vaultB.id), remainingIds) + } + + @Test + fun `clears context to NoSpecific and keeps last interacted when deleting the current vault`() = runTest { + vaultRepository.seed(vaultA, vaultB) + vaultContextRepository.seedLastInteracted(vaultB.id) + vaultContextRepository.setVaultContext(VaultContext.ById(vaultA.id)) + + useCase(vaultA.id) + + assertEquals(VaultContext.NoSpecific, vaultContextRepository.currentContext) + assertEquals(vaultB.id, vaultContextRepository.currentLastInteracted) + } + + @Test + fun `keeps context unchanged when deleting a non-current vault`() = runTest { + vaultRepository.seed(vaultA, vaultB) + vaultContextRepository.seedLastInteracted(vaultB.id) + vaultContextRepository.setVaultContext(VaultContext.ById(vaultB.id)) + + useCase(vaultA.id) + + assertEquals(VaultContext.ById(vaultB.id), vaultContextRepository.currentContext) + assertEquals(vaultB.id, vaultContextRepository.currentLastInteracted) + } + + @Test + fun `keeps NoSpecific context unchanged when context was already NoSpecific`() = runTest { + vaultRepository.seed(vaultA, vaultB) + vaultContextRepository.seedLastInteracted(vaultB.id) + + useCase(vaultA.id) + + assertEquals(VaultContext.NoSpecific, vaultContextRepository.currentContext) + } + + @Test + fun `switches last interacted to the most recent remaining vault when deleting the last interacted one`() = + runTest { + vaultRepository.seed(vaultA, vaultB, vaultC) + vaultContextRepository.seedLastInteracted(vaultC.id) + + useCase(vaultC.id) + + assertEquals(vaultB.id, vaultContextRepository.currentLastInteracted) + } + + @Test + fun `keeps last interacted unchanged when deleting a different vault`() = runTest { + vaultRepository.seed(vaultA, vaultB) + vaultContextRepository.seedLastInteracted(vaultB.id) + + useCase(vaultA.id) + + assertEquals(vaultB.id, vaultContextRepository.currentLastInteracted) + } + + @Test + fun `clears context and reroutes last interacted when deleting a vault that is both current and last interacted`() = + runTest { + vaultRepository.seed(vaultA, vaultB) + vaultContextRepository.seedLastInteracted(vaultB.id) + vaultContextRepository.setVaultContext(VaultContext.ById(vaultB.id)) + + useCase(vaultB.id) + + assertEquals(VaultContext.NoSpecific, vaultContextRepository.currentContext) + assertEquals(vaultA.id, vaultContextRepository.currentLastInteracted) + } + + @Test + fun `updates last interacted but keeps context when deleting last interacted that is not the current vault`() = + runTest { + vaultRepository.seed(vaultA, vaultB, vaultC) + vaultContextRepository.seedLastInteracted(vaultC.id) + vaultContextRepository.setVaultContext(VaultContext.ById(vaultA.id)) + + useCase(vaultC.id) + + assertEquals(VaultContext.ById(vaultA.id), vaultContextRepository.currentContext) + assertEquals(vaultB.id, vaultContextRepository.currentLastInteracted) + } + + @Test + fun `clears context and last interacted when deleting the only vault`() = + runTest { + vaultRepository.seed(vaultA) + vaultContextRepository.seedLastInteracted(vaultA.id) + vaultContextRepository.setVaultContext(VaultContext.ById(vaultA.id)) + + useCase(vaultA.id) + + assertEquals(VaultContext.NoSpecific, vaultContextRepository.currentContext) + assertEquals(null, vaultContextRepository.currentLastInteracted) + } + + private fun testVault( + name: String, + id: VaultId = newVaultId(), + ) = Vault( + id = id, + name = name, + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + icon = Vault.Icon.Default, + ) +} diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/EditVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/EditVaultUseCaseTest.kt new file mode 100644 index 00000000..e51e7cce --- /dev/null +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/EditVaultUseCaseTest.kt @@ -0,0 +1,154 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.isSuccess +import de.davis.keygo.feature.vault.domain.model.VaultCreationError +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class EditVaultUseCaseTest { + + private val vaultRepository = FakeVaultRepository() + private val useCase = EditVaultUseCase(vaultRepository) + + private val existingVault = Vault( + id = newVaultId(), + name = "Original", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + icon = Vault.Icon.Default, + ) + + @Test + fun `returns BlankName when name is empty`() = runTest { + val result = useCase(existingVault.id, name = "", icon = Vault.Icon.Default) + + assertTrue(result.isFailure()) + assertEquals(VaultCreationError.BlankName, result.error) + } + + @Test + fun `returns BlankName when name is whitespace only`() = runTest { + val result = useCase(existingVault.id, name = " ", icon = Vault.Icon.Default) + + assertTrue(result.isFailure()) + assertEquals(VaultCreationError.BlankName, result.error) + } + + @Test + fun `does not update vault when name is blank`() = runTest { + vaultRepository.seed(existingVault) + + useCase(existingVault.id, name = "", icon = Vault.Icon.Work) + + val stored = vaultRepository.observeVaults().first().single() + assertEquals("Original", stored.name) + assertEquals(Vault.Icon.Default, stored.icon) + } + + @Test + fun `returns Success for valid name`() = runTest { + vaultRepository.seed(existingVault) + + val result = useCase(existingVault.id, name = "Updated", icon = Vault.Icon.Default) + + assertTrue(result.isSuccess()) + } + + @Test + fun `updates vault name`() = runTest { + vaultRepository.seed(existingVault) + + useCase(existingVault.id, name = "Updated", icon = Vault.Icon.Default) + + val stored = vaultRepository.observeVaults().first().single() + assertEquals("Updated", stored.name) + } + + @Test + fun `trims leading and trailing whitespace from name before storing`() = runTest { + vaultRepository.seed(existingVault) + + useCase(existingVault.id, name = " Trimmed ", icon = Vault.Icon.Default) + + val stored = vaultRepository.observeVaults().first().single() + assertEquals("Trimmed", stored.name) + } + + @Test + fun `name with only internal whitespace is stored as-is`() = runTest { + vaultRepository.seed(existingVault) + + useCase(existingVault.id, name = "My Vault", icon = Vault.Icon.Default) + + val stored = vaultRepository.observeVaults().first().single() + assertEquals("My Vault", stored.name) + } + + @Test + fun `updates vault icon`() = runTest { + vaultRepository.seed(existingVault) + + useCase(existingVault.id, name = "Original", icon = Vault.Icon.Work) + + val stored = vaultRepository.observeVaults().first().single() + assertEquals(Vault.Icon.Work, stored.icon) + } + + @Test + fun `does not change vault id`() = runTest { + vaultRepository.seed(existingVault) + + useCase(existingVault.id, name = "Renamed", icon = Vault.Icon.Default) + + val stored = vaultRepository.observeVaults().first().single() + assertEquals(existingVault.id, stored.id) + } + + @Test + fun `does not change key information`() = runTest { + val keyInfo = KeyInformation( + wrappedKey = byteArrayOf(1, 2, 3), + keyNonce = byteArrayOf(4, 5, 6), + ) + val vault = Vault( + id = newVaultId(), + name = "Secure", + keyInformation = keyInfo, + icon = Vault.Icon.Default, + ) + vaultRepository.seed(vault) + + useCase(vault.id, name = "Renamed", icon = Vault.Icon.Home) + + val stored = vaultRepository.observeVaults().first().single() + assertTrue(stored.keyInformation.wrappedKey.contentEquals(keyInfo.wrappedKey)) + assertTrue(stored.keyInformation.keyNonce.contentEquals(keyInfo.keyNonce)) + } + + @Test + fun `can update to every available icon`() = runTest { + vaultRepository.seed(existingVault) + + Vault.Icon.entries.forEach { icon -> + useCase(existingVault.id, name = "V", icon = icon) + assertEquals(icon, vaultRepository.observeVaults().first().single().icon) + } + } + + @Test + fun `updating a single-character name succeeds`() = runTest { + vaultRepository.seed(existingVault) + + val result = useCase(existingVault.id, name = "X", icon = Vault.Icon.Default) + + assertTrue(result.isSuccess()) + assertEquals("X", vaultRepository.observeVaults().first().single().name) + } +} diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt new file mode 100644 index 00000000..def9e6c3 --- /dev/null +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/MoveItemsToVaultUseCaseTest.kt @@ -0,0 +1,379 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.FakeItemRepository +import de.davis.keygo.core.item.FakePasswordRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.DomainInfo +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Password +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.security.crypto.BindingCryptographicScopeProvider +import de.davis.keygo.core.security.crypto.FakeSession +import de.davis.keygo.core.security.domain.crypto.CryptographicScopeProvider +import de.davis.keygo.core.security.domain.crypto.decryptSecretData +import de.davis.keygo.core.security.domain.crypto.encryptSecretData +import de.davis.keygo.core.security.domain.crypto.model.WrappedItemKeyInformation +import de.davis.keygo.core.security.domain.crypto.model.WrappedVaultKeyInformation +import de.davis.keygo.core.security.domain.crypto.wrappedItemKeyInformation +import de.davis.keygo.core.util.isFailure +import de.davis.keygo.core.util.isSuccess +import de.davis.keygo.feature.vault.domain.model.MoveItemsError +import de.davis.keygo.feature.vault.domain.model.MoveItemsProgress +import de.davis.keygo.rust.FakeItemManager +import de.davis.keygo.rust.FakeKeyWrapper +import de.davisalessandro.keygo.rust.ItemAad +import de.davisalessandro.keygo.rust.KeyWrapException +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class MoveItemsToVaultUseCaseTest { + + private val session = FakeSession() + private val itemManager = FakeItemManager() + private val keyWrapper = FakeKeyWrapper() + private val cryptographicScopeProvider: CryptographicScopeProvider = + BindingCryptographicScopeProvider(session, itemManager, keyWrapper) + + private val passwordRepository = FakePasswordRepository() + private val itemRepository = FakeItemRepository(passwordRepository) + private val vaultRepository = FakeVaultRepository() + + private val useCase = MoveItemsToVaultUseCase( + cryptographicScopeProvider = cryptographicScopeProvider, + itemRepository = itemRepository, + vaultRepository = vaultRepository, + ) + + private val srcVault = makeVault("Source") + private val dstVault = makeVault("Destination") + + @Test + fun `moved password and totp decrypt under destination vault`() = runTest { + seedVaults(srcVault, dstVault) + val passwordPlaintext = "hunter2" + val totpPlaintext = "JBSWY3DPEHPK3PXP" + val seeded = encryptAndSeed( + srcVault, + passwordPlaintext = passwordPlaintext, + totpPlaintext = totpPlaintext, + ) + + val result = useCase(srcVault.id, dstVault.id) + + assertTrue(result.isSuccess()) + val moved = passwordRepository.getPasswordById(seeded.id) + assertNotNull(moved) + assertEquals(dstVault.id, moved.vaultId) + val (recoveredPassword, recoveredTotp) = decrypt(moved, dstVault) + assertEquals(passwordPlaintext, recoveredPassword) + assertEquals(totpPlaintext, recoveredTotp) + } + + @Test + fun `moved item gets a fresh wrapped key`() = runTest { + seedVaults(srcVault, dstVault) + val seeded = encryptAndSeed(srcVault, passwordPlaintext = "x") + + useCase(srcVault.id, dstVault.id) + + val moved = passwordRepository.getPasswordById(seeded.id)!! + assertNotEquals( + seeded.keyInformation.wrappedKey.toList(), + moved.keyInformation.wrappedKey.toList(), + ) + } + + @Test + fun `null totp survives the move as null`() = runTest { + seedVaults(srcVault, dstVault) + val seeded = encryptAndSeed(srcVault, passwordPlaintext = "x", totpPlaintext = null) + + useCase(srcVault.id, dstVault.id) + + val moved = passwordRepository.getPasswordById(seeded.id)!! + assertNull(moved.totpSecret) + } + + @Test + fun `plaintext fields are unchanged through the move`() = runTest { + seedVaults(srcVault, dstVault) + val seeded = encryptAndSeed( + srcVault, + name = "Acme", + username = "alice", + note = "shared corp account", + score = Password.Score.Strong, + domainInfos = setOf(DomainInfo(value = "acme.com", eTLD1 = "acme.com")), + pinned = true, + passwordPlaintext = "x", + ) + + useCase(srcVault.id, dstVault.id) + + val moved = passwordRepository.getPasswordById(seeded.id)!! + assertEquals(seeded.name, moved.name) + assertEquals(seeded.username, moved.username) + assertEquals(seeded.note, moved.note) + assertEquals(seeded.score, moved.score) + assertEquals(seeded.domainInfos, moved.domainInfos) + assertEquals(seeded.pinned, moved.pinned) + } + + @Test + fun `same source and destination is a no-op success`() = runTest { + seedVaults(srcVault) + val seeded = encryptAndSeed(srcVault, passwordPlaintext = "x") + + val result = useCase(srcVault.id, srcVault.id) + + assertTrue(result.isSuccess()) + val after = passwordRepository.getPasswordById(seeded.id)!! + assertEquals(srcVault.id, after.vaultId) + assertEquals( + seeded.keyInformation.wrappedKey.toList(), + after.keyInformation.wrappedKey.toList() + ) + } + + @Test + fun `missing source vault fails with VaultNotFound`() = runTest { + seedVaults(dstVault) + val missingId = newVaultId() + + val result = useCase(missingId, dstVault.id) + + assertTrue(result.isFailure()) + val error = assertIs(result.error) + assertEquals(missingId, error.vaultId) + } + + @Test + fun `missing destination vault fails with VaultNotFound`() = runTest { + seedVaults(srcVault) + val missingId = newVaultId() + + val result = useCase(srcVault.id, missingId) + + assertTrue(result.isFailure()) + val error = assertIs(result.error) + assertEquals(missingId, error.vaultId) + } + + @Test + fun `empty source vault is a success`() = runTest { + seedVaults(srcVault, dstVault) + + val result = useCase(srcVault.id, dstVault.id) + + assertTrue(result.isSuccess()) + } + + @Test + fun `multiple items all migrate to the destination vault`() = runTest { + seedVaults(srcVault, dstVault) + val seededIds = (1..3).map { + encryptAndSeed(srcVault, name = "item-$it", passwordPlaintext = "pw-$it").id + } + + val result = useCase(srcVault.id, dstVault.id) + + assertTrue(result.isSuccess()) + seededIds.forEach { id -> + val moved = passwordRepository.getPasswordById(id)!! + assertEquals(dstVault.id, moved.vaultId) + } + } + + @Test + fun `persist failure rolls back all items and surfaces PersistFailed`() = runTest { + seedVaults(srcVault, dstVault) + val seededIds = (1..3).map { + encryptAndSeed(srcVault, name = "item-$it", passwordPlaintext = "pw-$it").id + } + val cause = RuntimeException("write blew up") + itemRepository.failMoveForId = seededIds[1] to cause + + val result = useCase(srcVault.id, dstVault.id) + + assertTrue(result.isFailure()) + val error = assertIs(result.error) + assertSame(cause, error.cause) + + seededIds.forEach { id -> + assertEquals(srcVault.id, passwordRepository.getPasswordById(id)!!.vaultId) + } + } + + @Test + fun `rewrap failure surfaces ItemMoveFailed and leaves all items in src`() = runTest { + seedVaults(srcVault, dstVault) + val seededIds = (1..3).map { + encryptAndSeed(srcVault, name = "item-$it", passwordPlaintext = "pw-$it").id + } + val failingId = seededIds[1] + val cause = KeyWrapException.UnwrapFailed() + keyWrapper.failUnwrapItemForId = failingId to cause + + val result = useCase(srcVault.id, dstVault.id) + + assertTrue(result.isFailure()) + val error = assertIs(result.error) + assertEquals(failingId, error.itemId) + assertSame(cause, error.cause) + + seededIds.forEach { id -> + assertEquals(srcVault.id, passwordRepository.getPasswordById(id)!!.vaultId) + } + } + + @Test + fun `onProgress emits zero then increments up to total`() = runTest { + seedVaults(srcVault, dstVault) + val total = 3 + repeat(total) { i -> + encryptAndSeed(srcVault, name = "item-$i", passwordPlaintext = "pw-$i") + } + val captured = mutableListOf() + + val result = useCase(srcVault.id, dstVault.id) { captured += it } + + assertTrue(result.isSuccess()) + assertEquals( + expected = listOf( + MoveItemsProgress(movedCount = 0, total = total), + MoveItemsProgress(movedCount = 1, total = total), + MoveItemsProgress(movedCount = 2, total = total), + MoveItemsProgress(movedCount = 3, total = total), + ), + actual = captured, + ) + } + + @Test + fun `onProgress emits a single zero-of-zero for an empty vault`() = runTest { + seedVaults(srcVault, dstVault) + val captured = mutableListOf() + + useCase(srcVault.id, dstVault.id) { captured += it } + + assertEquals(listOf(MoveItemsProgress(movedCount = 0, total = 0)), captured) + } + + @Test + fun `onProgress is not invoked for a same-vault no-op`() = runTest { + seedVaults(srcVault) + encryptAndSeed(srcVault, passwordPlaintext = "x") + val captured = mutableListOf() + + useCase(srcVault.id, srcVault.id) { captured += it } + + assertTrue(captured.isEmpty()) + } + + @Test + fun `onProgress stops emitting once a rewrap fails`() = runTest { + seedVaults(srcVault, dstVault) + val seededIds = (1..3).map { + encryptAndSeed(srcVault, name = "item-$it", passwordPlaintext = "pw-$it").id + } + keyWrapper.failUnwrapItemForId = seededIds[1] to KeyWrapException.UnwrapFailed() + val captured = mutableListOf() + + useCase(srcVault.id, dstVault.id) { captured += it } + + assertEquals( + expected = listOf( + MoveItemsProgress(movedCount = 0, total = 3), + MoveItemsProgress(movedCount = 1, total = 3), + ), + actual = captured, + ) + } + + // --- helpers --- + + private fun makeVault(name: String, id: VaultId = newVaultId()): Vault { + val vaultKey = ByteArray(32) { (id.hashCode() + it).toByte() } + val wrapped = keyWrapper.wrapVaultKey( + ark = session.dek.key.encoded, + vaultKey = vaultKey, + vaultId = id, + ) + return Vault( + id = id, + name = name, + keyInformation = KeyInformation( + wrappedKey = wrapped.ciphertext, + keyNonce = wrapped.nonce, + ), + icon = Vault.Icon.Default, + ) + } + + private suspend fun seedVaults(vararg vaults: Vault) { + vaultRepository.seed(*vaults) + } + + private suspend fun encryptAndSeed( + vault: Vault, + id: ItemId = newItemId(), + name: String = "item", + username: String? = null, + note: String? = null, + score: Password.Score = Password.Score.Strong, + domainInfos: Set = emptySet(), + pinned: Boolean = false, + passwordPlaintext: String, + totpPlaintext: String? = null, + ): Password { + val aad = ItemAad(itemId = id, vaultId = vault.id) + val password = cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = vault.keyInformation, + vaultId = vault.id, + ), + wrappedItemKeyInformation = WrappedItemKeyInformation(itemAad = aad), + ) { + Password( + id = id, + name = name, + username = username, + domainInfos = domainInfos, + score = score, + password = passwordPlaintext.encryptSecretData(label = Password.LABEL_PASSWORD), + totpSecret = totpPlaintext?.encryptSecretData(label = Password.LABEL_TOTP_SECRET), + note = note, + pinned = pinned, + vaultId = vault.id, + keyInformation = wrapCurrentItemKey(), + ) + } + passwordRepository.seed(password) + return password + } + + private suspend fun decrypt(password: Password, vault: Vault): Pair = + cryptographicScopeProvider.itemScope( + wrappedVaultKeyInformation = WrappedVaultKeyInformation( + wrappedVaultKey = vault.keyInformation, + vaultId = vault.id, + ), + wrappedItemKeyInformation = password.wrappedItemKeyInformation(), + ) { + val pw: String = password.password.decryptSecretData(label = Password.LABEL_PASSWORD) + val totp: String? = + password.totpSecret?.decryptSecretData(label = Password.LABEL_TOTP_SECRET) + pw to totp + } +} diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt new file mode 100644 index 00000000..e1f2f41f --- /dev/null +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/ObserveVaultsAndSelectionUseCaseTest.kt @@ -0,0 +1,89 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.FakeVaultRepository +import de.davis.keygo.core.item.domain.alias.VaultId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Vault +import de.davis.keygo.core.item.domain.model.VaultContext +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ObserveVaultsAndSelectionUseCaseTest { + + private val vaultA = testVault("Personal") + private val vaultB = testVault("Work") + + private val vaultRepository = FakeVaultRepository() + private val vaultContextRepository = FakeVaultContextRepository() + private val useCase = ObserveVaultsAndSelectionUseCase( + vaultRepository = vaultRepository, + vaultContextRepository = vaultContextRepository, + ) + + @Test + fun `emits empty vaults and NoSpecific when no vaults seeded and no context set`() = runTest { + val result = useCase().first() + + assertEquals(emptyList(), result.vaults) + assertEquals(VaultContext.NoSpecific, result.selection) + } + + @Test + fun `emits seeded vaults with NoSpecific when context is NoSpecific`() = runTest { + vaultRepository.seed(vaultA, vaultB) + + val result = useCase().first() + + assertEquals(setOf(vaultA.id, vaultB.id), result.vaults.map { it.vaultId }.toSet()) + assertEquals(VaultContext.NoSpecific, result.selection) + } + + @Test + fun `emits context set via VaultContextRepository`() = runTest { + vaultRepository.seed(vaultA) + vaultContextRepository.setVaultContext(VaultContext.ById(vaultA.id)) + + val result = useCase().first() + + val selection = assertIs(result.selection) + assertEquals(vaultA.id, selection.vaultId) + } + + @Test + fun `maps vaults to metadata with name and icon`() = runTest { + vaultRepository.seed(vaultA) + + val metadata = useCase().first().vaults.single() + + assertEquals(vaultA.id, metadata.vaultId) + assertEquals(vaultA.name, metadata.name) + assertEquals(vaultA.icon, metadata.icon) + } + + @Test + fun `sorts vaults by name`() = runTest { + val zebra = testVault("Zebra") + val alpha = testVault("Alpha") + val mango = testVault("Mango") + vaultRepository.seed(zebra, alpha, mango) + + val names = useCase().first().vaults.map { it.name } + + assertEquals(listOf("Alpha", "Mango", "Zebra"), names) + } + + private fun testVault( + name: String, + id: VaultId = newVaultId(), + ) = Vault( + id = id, + name = name, + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + icon = Vault.Icon.Default, + ) +} diff --git a/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/SetVaultContextUseCaseTest.kt b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/SetVaultContextUseCaseTest.kt new file mode 100644 index 00000000..f5fff2f0 --- /dev/null +++ b/feature/vault/src/test/kotlin/de/davis/keygo/feature/vault/domain/usecase/SetVaultContextUseCaseTest.kt @@ -0,0 +1,67 @@ +package de.davis.keygo.feature.vault.domain.usecase + +import de.davis.keygo.core.item.FakeVaultContextRepository +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.VaultContext +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SetVaultContextUseCaseTest { + + private val vaultIdA = newVaultId() + private val vaultIdB = newVaultId() + + private val vaultContextRepository = FakeVaultContextRepository() + private val useCase = SetVaultContextUseCase(vaultContextRepository) + + @Test + fun `setting ById persists context`() = runTest { + useCase(VaultContext.ById(vaultIdA)) + + assertEquals(VaultContext.ById(vaultIdA), vaultContextRepository.observeVaultContext().first()) + } + + @Test + fun `setting ById updates last interacted vault`() = runTest { + useCase(VaultContext.ById(vaultIdA)) + + assertEquals(vaultIdA, vaultContextRepository.currentLastInteracted) + } + + @Test + fun `setting NoSpecific persists context`() = runTest { + useCase(VaultContext.NoSpecific) + + assertEquals(VaultContext.NoSpecific, vaultContextRepository.observeVaultContext().first()) + } + + @Test + fun `setting NoSpecific does not update last interacted vault`() = runTest { + useCase(VaultContext.NoSpecific) + + assertNull(vaultContextRepository.currentLastInteracted) + } + + @Test + fun `setting NoSpecific after ById preserves last interacted vault`() = runTest { + useCase(VaultContext.ById(vaultIdA)) + + useCase(VaultContext.NoSpecific) + + assertEquals(vaultIdA, vaultContextRepository.currentLastInteracted) + assertEquals(VaultContext.NoSpecific, vaultContextRepository.observeVaultContext().first()) + } + + @Test + fun `re-selecting a different ById updates both context and last interacted`() = runTest { + useCase(VaultContext.ById(vaultIdA)) + + useCase(VaultContext.ById(vaultIdB)) + + assertEquals(VaultContext.ById(vaultIdB), vaultContextRepository.observeVaultContext().first()) + assertEquals(vaultIdB, vaultContextRepository.currentLastInteracted) + } +} diff --git a/gradle.properties b/gradle.properties index 6a1eaa15..132244e5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,6 +21,3 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -# Temporary: required for protobuf-gradle-plugin 0.9.6 compatibility with AGP 9. -# Remove once protobuf-gradle-plugin supports AGP 9's new DSL. -android.newDsl=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cacf8fa6..f285813d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ minSdk = "26" agp = "9.1.0" autofill = "1.3.0" -argon2kt = "1.6.0" accompanist = "0.37.3" camera = "1.5.3" credentials = "1.6.0-rc02" @@ -22,14 +21,14 @@ activityCompose = "1.13.0" composeBom = "2026.03.00" mockk = "1.14.9" semver = "0.2.5" -koinBOM = "4.2.0" -koin-plugin = "0.4.1" +koinBOM = "4.2.1" +koin-plugin = "1.0.0-RC1" room = "2.8.4" ksp = "2.3.6" datastore = "1.2.1" -protobuf = "0.9.6" -protoc = "4.34.0" -coroutinesTest = "1.10.2" +protobuf = "0.10.0" +protoc = "4.34.1" +coroutines = "1.10.2" bcrypt = "0.10.2" biometric = "1.4.0-alpha05" navigation = "2.9.7" @@ -39,12 +38,12 @@ totp = "2.4.1" passGen = "0.1.0-beta" [libraries] -argon2kt = { module = "com.lambdapioneer.argon2kt:argon2kt", version.ref = "argon2kt" } google-protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protoc" } google-protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protoc" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" } -kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinSerializationJson" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "collectionsImmutable" } @@ -53,6 +52,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-animation-graphics = { group = "androidx.compose.animation", name = "animation-graphics" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } diff --git a/rust/.gitignore b/rust/.gitignore index b2e6949d..900aa1dc 100644 --- a/rust/.gitignore +++ b/rust/.gitignore @@ -6,10 +6,6 @@ debug/ target/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries -# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock - # These are backup files generated by rustfmt **/*.rs.bk diff --git a/rust/build.gradle.kts b/rust/build.gradle.kts index f73a0418..f79ca010 100644 --- a/rust/build.gradle.kts +++ b/rust/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.library) + alias(libs.plugins.koin.compiler) } android { @@ -34,6 +35,21 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } + + // Ensure UniFFI-generated Kotlin sources are compiled. The UniFFI Makefile + // outputs Kotlin into build/generated/source/uniffi/, which isn't a + // conventional AGP-generated directory. Register it as a source dir so + // the Kotlin/Java compiler picks up the generated bindings. + sourceSets { + getByName("main") { + jniLibs.directories += "build/generated/source/uniffi/jniLibs" + kotlin.directories += "build/generated/source/uniffi/kotlin" + } + } + + testFixtures { + enable = true + } } kotlin { @@ -48,6 +64,15 @@ dependencies { api(projects.core.util) + // Koin DI + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) + implementation(libs.koin.annotations) + + implementation("net.java.dev.jna:jna:5.18.1@aar") + // kotlinx-coroutines-core is also required for async uniffi functions: + implementation(libs.kotlinx.coroutines.core) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } @@ -57,15 +82,15 @@ val buildRust by tasks.register("buildRust") { description = "Build Rust library" onlyIf { - properties["buildRust"].toString().toBoolean() + providers.gradleProperty("rust.compile").getOrElse("true").toBoolean() } workingDir = projectDir.resolve("rust-code") commandLine( - "./build.sh", - "--min-platform", - libs.versions.minSdk.get() + "make", + "all", + "MIN_SDK=${libs.versions.minSdk.get()}", ) } diff --git a/rust/rust-code/.cargo/config.toml b/rust/rust-code/.cargo/config.toml new file mode 100644 index 00000000..f013054f --- /dev/null +++ b/rust/rust-code/.cargo/config.toml @@ -0,0 +1,26 @@ +# Android NDK cross-compile linkers. +# +# Requires the NDK toolchain `bin/` on PATH, e.g.: +# export PATH="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin:$PATH" +# +# Min SDK: 26 (matches app/minSdk). Bump the API number in the linker name +# if the app minSdk changes. +# +# Prefer `cargo ndk -t build --release -p keygo-bindings` over setting +# PATH manually — it picks the right linker/sysroot automatically. + +[target.aarch64-linux-android] +linker = "aarch64-linux-android26-clang" +ar = "llvm-ar" + +[target.armv7-linux-androideabi] +linker = "armv7a-linux-androideabi26-clang" +ar = "llvm-ar" + +[target.x86_64-linux-android] +linker = "x86_64-linux-android26-clang" +ar = "llvm-ar" + +[target.i686-linux-android] +linker = "i686-linux-android26-clang" +ar = "llvm-ar" diff --git a/rust/rust-code/Cargo.lock b/rust/rust-code/Cargo.lock new file mode 100644 index 00000000..61944ef8 --- /dev/null +++ b/rust/rust-code/Cargo.lock @@ -0,0 +1,2400 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.6.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b657e772794c6b04730ea897b66a058ccd866c16d1967da05eeeecec39043fe" +dependencies = [ + "crypto-common 0.2.1", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.12.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d2f4cb729b8b58144551d208643a39915a039ca78659e7cec9c80789d1695b5" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "askama" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +dependencies = [ + "askama_derive", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn 2.0.117", +] + +[[package]] +name = "askama_parser" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +dependencies = [ + "memchr", + "serde", + "serde_derive", + "winnow 0.7.15", +] + +[[package]] +name = "async-compat" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1ba85bc55464dcbf728b56d97e119d673f4cf9062be330a9a26f3acf504a590" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bcs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "302fd2afb39688efc82f6138a68861b8f04f95be5faab2a06805a8a49f28deb6" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cipher" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "coset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1eb98d5e9155e2cf7cd942c8b3033097d4563b6fb0a00b9caecb74669555c058" +dependencies = [ + "ciborium", + "ciborium-io", +] + +[[package]] +name = "cpubits" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef0c543070d296ea414df2dd7625d1b24866ce206709d8a4a424f28377f5861" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17469f8eb9bdbfad10f71f4cfddfd38b01143520c0e717d8796ccb4d44d44e42" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.11.2", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki", +] + +[[package]] +name = "ed25519" +version = "3.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" +dependencies = [ + "signature 3.0.0-rc.10", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.10.1", + "sha2 0.11.0", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "base64ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serde_json", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fragile" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "goblin" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b363a30c165f666402fe6a3024d3bec7ebc898f96a4a23bd1c99f8dbf3f4f47" +dependencies = [ + "log", + "plain", + "scroll", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "keygo-bindings" +version = "0.1.0" +dependencies = [ + "lib", + "thiserror", + "tokio", + "uniffi", + "uuid", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lib" +version = "0.1.0" +dependencies = [ + "aead", + "aes-gcm-siv", + "argon2", + "async-trait", + "bcs", + "ciborium", + "coset", + "ed25519-dalek", + "passkey", + "passkey-authenticator", + "passkey-types", + "rand 0.10.1", + "serde", + "serde_json", + "thiserror", + "url", + "uuid", + "zeroize", +] + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mockall" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c84490118f2ee2d74570d114f3d0493cbf02790df303d2707606c3e14e07c96" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "lazy_static", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22ce75669015c4f47b289fd4d4f56e894e4c96003ffdf3ac51313126f94c6cbb" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "passkey" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47eb8971740eff7e395beb540a88a0fc302c205b433ff437f2c4759cba0c5ec" +dependencies = [ + "passkey-authenticator", + "passkey-client", + "passkey-transports", + "passkey-types", +] + +[[package]] +name = "passkey-authenticator" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20bf5800e3f6287580da985fb88e678f37c078d62242cb536941c44d852ab37c" +dependencies = [ + "async-trait", + "coset", + "log", + "mockall", + "p256", + "passkey-types", + "rand 0.8.6", + "tokio", +] + +[[package]] +name = "passkey-client" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99509cbbdfe526c617e8265716fba277562a7d771fef047ac9e25686d1c502" +dependencies = [ + "ciborium", + "coset", + "idna", + "itertools 0.14.0", + "passkey-authenticator", + "passkey-types", + "public-suffix", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "passkey-transports" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04110fadc73577990f04153a2508989242d9c90a645aeaad98ef30e0decfbb" + +[[package]] +name = "passkey-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a1fae06bd6bc619675d30b50cb38ca649837cc9e6a92a00b14743cfbdb1390" +dependencies = [ + "bitflags", + "ciborium", + "coset", + "data-encoding", + "getrandom 0.2.17", + "hmac", + "indexmap", + "p256", + "rand 0.8.6", + "serde", + "serde_json", + "sha2 0.10.9", + "strum", + "url", + "zeroize", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polyval" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59230a63c37f3e18569bdb90e4a89cbf5bf8b06fea0b84e65ea10cc4df47addd" +dependencies = [ + "difflib", + "float-cmp", + "itertools 0.10.5", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "public-suffix" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51315bca45305dd8aa64b831b33e71abac528ca8058c0651346a39b8d3009498" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.2", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uniffi" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470" +dependencies = [ + "anyhow", + "camino", + "cargo_metadata", + "clap", + "uniffi_bindgen", + "uniffi_build", + "uniffi_core", + "uniffi_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_bindgen" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c" +dependencies = [ + "anyhow", + "askama", + "camino", + "cargo_metadata", + "fs-err", + "glob", + "goblin", + "heck", + "indexmap", + "once_cell", + "serde", + "tempfile", + "textwrap", + "toml", + "uniffi_internal_macros", + "uniffi_meta", + "uniffi_pipeline", + "uniffi_udl", +] + +[[package]] +name = "uniffi_build" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c39413c43b955e4aa8a4e2b34bbd1b6b5ff6bd85532b52f9eb92fbe88c14458" +dependencies = [ + "anyhow", + "camino", + "uniffi_bindgen", +] + +[[package]] +name = "uniffi_core" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f" +dependencies = [ + "anyhow", + "async-compat", + "bytes", + "once_cell", + "static_assertions", +] + +[[package]] +name = "uniffi_internal_macros" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" +dependencies = [ + "anyhow", + "indexmap", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "uniffi_macros" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561" +dependencies = [ + "camino", + "fs-err", + "once_cell", + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "toml", + "uniffi_meta", +] + +[[package]] +name = "uniffi_meta" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18" +dependencies = [ + "anyhow", + "siphasher", + "uniffi_internal_macros", + "uniffi_pipeline", +] + +[[package]] +name = "uniffi_pipeline" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "tempfile", + "uniffi_internal_macros", +] + +[[package]] +name = "uniffi_udl" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370" +dependencies = [ + "anyhow", + "textwrap", + "uniffi_meta", + "weedle2", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.1", + "ctutils", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "weedle2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998d2c24ec099a87daf9467808859f9d82b61f1d9c9701251aea037f514eae0e" +dependencies = [ + "nom", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/rust/rust-code/Cargo.toml b/rust/rust-code/Cargo.toml index 3fc0a1f5..8dd6a9ad 100644 --- a/rust/rust-code/Cargo.toml +++ b/rust/rust-code/Cargo.toml @@ -1,23 +1,6 @@ -[package] -name = "keygo-rust-lib" -version = "0.1.0" -edition = "2024" - -[lib] -name = "keygo_rust" -crate-type = ["cdylib"] - -[dependencies] -jni = "0.21.1" -serde_json = "1.0.145" -tokio = { version = "1.48.0", features = ["rt", "macros"] } -passkey = "0.4.0" -coset = "0.3.8" # Older version -> see dep tree of passkey -url = "2.5.7" -thiserror = "2.0.17" -async-trait = "0.1.89" -serde = { version = "1.0.228", features = ["derive"] } -bincode = { version = "2.0.1", features = ["serde"] } - -# Needed so passkey JSON responses are serialized into base64 strings -passkey-types = { version = "0.4.0", features = ["serialize_bytes_as_base64_string"] } +[workspace] +members = [ + "lib", + "bindings", +] +resolver = "3" diff --git a/rust/rust-code/Makefile b/rust/rust-code/Makefile new file mode 100644 index 00000000..c8d29fed --- /dev/null +++ b/rust/rust-code/Makefile @@ -0,0 +1,67 @@ +# KeyGo Rust build orchestrator. Run `make help` for usage. + +ABIS ?= arm64-v8a armeabi-v7a x86_64 +MIN_SDK ?= 26 +PROFILE ?= release +CRATE := keygo-bindings +LIB_NAME := libkeygo_bindings.so +JNI_DIR := ../build/generated/source/uniffi/jniLibs +KOTLIN_OUT := ../build/generated/source/uniffi/kotlin/ +BINDGEN_ABI := arm64-v8a +BINDGEN_TRIPLE := aarch64-linux-android + +# Rustup targets matching the Android ABIs above. +RUST_TARGETS := aarch64-linux-android armv7-linux-androideabi x86_64-linux-android + +CARGO_NDK_FLAGS := $(foreach abi,$(ABIS),-t $(abi)) --platform $(MIN_SDK) + +ifdef NDK + export ANDROID_NDK_HOME := $(NDK) +endif + +.PHONY: help all build bindgen tools check fmt test clean + +help: ## Show this help + @printf "Usage: make [target] [VAR=value]\n\nTargets:\n" + @awk -F ':.*?## ' '/^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + @printf "\nVariables:\n" + @awk '/^# [A-Z_]+ .*= / {sub(/^# /, " "); print}' $(MAKEFILE_LIST) + +## Variables +# ABIS ?= arm64-v8a armeabi-v7a x86_64 Android ABIs to build +# MIN_SDK ?= 26 Android API level for the NDK linker +# PROFILE ?= release Cargo profile (release|debug) +# NDK ?= $$ANDROID_NDK_HOME Path to the Android NDK + +all: build bindgen ## Build cdylibs and generate Kotlin bindings + +tools: ## Install cargo-ndk and missing rustup targets (idempotent) + @command -v cargo >/dev/null || { echo "cargo not found; install Rust via rustup" >&2; exit 1; } + @command -v cargo-ndk >/dev/null || cargo install cargo-ndk + @installed="$$(rustup target list --installed)"; \ + for t in $(RUST_TARGETS); do \ + echo "$$installed" | grep -qx "$$t" || rustup target add "$$t"; \ + done + +build: tools ## Build cdylibs per ABI into ../src/main/jniLibs + cargo ndk $(CARGO_NDK_FLAGS) -o $(JNI_DIR) build --$(PROFILE) -p $(CRATE) + +bindgen: build ## Generate Kotlin UniFFI bindings + cargo run -p $(CRATE) --bin uniffi-bindgen -- generate \ + --library target/$(BINDGEN_TRIPLE)/$(PROFILE)/$(LIB_NAME) \ + --language kotlin \ + --no-format \ + --out-dir $(KOTLIN_OUT) + +check: ## cargo check the workspace + cargo check --workspace + +fmt: ## Format Rust code with cargo fmt + cargo fmt --all + +test: ## cargo test the workspace + cargo test --workspace + +clean: ## Remove build artifacts and generated jniLibs + cargo clean + rm -rf $(JNI_DIR) diff --git a/rust/rust-code/bindings/Cargo.toml b/rust/rust-code/bindings/Cargo.toml new file mode 100644 index 00000000..ffcf045e --- /dev/null +++ b/rust/rust-code/bindings/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "keygo-bindings" +version = "0.1.0" +edition = "2024" + +[lib] +name = "keygo_bindings" +crate-type = ["cdylib", "staticlib"] + +[dependencies] +lib = { path = "../lib" } +thiserror = "2.0.18" +tokio = { version = "1.48.0", features = ["rt", "rt-multi-thread"] } +uniffi = { version = "0.31.1", features = ["tokio", "cli"] } +uuid = "1.23.1" + +[build-dependencies] +uniffi = { version = "0.31.1", features = ["build"] } + +[[bin]] +name = "uniffi-bindgen" +path = "uniffi-bindgen.rs" diff --git a/rust/rust-code/bindings/src/account.rs b/rust/rust-code/bindings/src/account.rs new file mode 100644 index 00000000..1afbdf49 --- /dev/null +++ b/rust/rust-code/bindings/src/account.rs @@ -0,0 +1,64 @@ +use lib::crypto::types::{UserId, VaultId}; +use lib::crypto::{AccountRootKey, KeyMaterial, VaultKey}; +use lib::item::account::Account; +use lib::item::create_account::CreateAccount; +use lib::item::vault::Vault; +use std::sync::Arc; +use uuid::Uuid; + +uniffi::custom_type!(Uuid, String, { + remote, + try_lift: |s| Uuid::parse_str(&s).map_err(|e| uniffi::deps::anyhow::anyhow!("{e}")), + lower: |u| u.to_string(), +}); + +uniffi::custom_type!(AccountRootKey, Vec, { + remote, + try_lift: |bytes| { + AccountRootKey::try_from_bytes(&bytes) + .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) + }, + lower: |key| key.as_bytes().to_vec(), +}); + +uniffi::custom_type!(VaultKey, Vec, { + remote, + try_lift: |bytes| { + VaultKey::try_from_bytes(&bytes) + .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) + }, + lower: |key| key.as_bytes().to_vec(), +}); + +#[uniffi::remote(Record)] +pub struct Account { + pub id: UserId, + pub ark: AccountRootKey, +} + +#[uniffi::remote(Record)] +pub struct Vault { + pub id: VaultId, + pub vault_key: VaultKey, +} + +#[uniffi::remote(Record)] +pub struct CreateAccount { + pub account: Account, + pub default_vault: Vault, +} + +#[derive(uniffi::Object)] +pub struct AccountManager; + +#[uniffi::export] +impl AccountManager { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub fn create_account(&self) -> CreateAccount { + CreateAccount::generate_new() + } +} diff --git a/rust/rust-code/bindings/src/item.rs b/rust/rust-code/bindings/src/item.rs new file mode 100644 index 00000000..d15546e8 --- /dev/null +++ b/rust/rust-code/bindings/src/item.rs @@ -0,0 +1,96 @@ +use lib::crypto::KeyMaterial; +use lib::crypto::error::CryptoError; +use lib::crypto::item_key::{ItemAad, ItemDataAad, ItemKey}; +use lib::crypto::primitive::aead_data::{AeadCiphertext, AeadEncryptor}; +use lib::crypto::types::{ItemId, VaultId}; +use std::sync::Arc; + +uniffi::custom_type!(ItemKey, Vec, { + remote, + try_lift: |bytes| { + ItemKey::try_from_bytes(&bytes) + .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) + }, + lower: |key| key.as_bytes().to_vec(), +}); + +#[uniffi::remote(Record)] +pub struct ItemAad { + pub item_id: ItemId, + pub vault_id: VaultId, +} + +#[derive(uniffi::Record)] +pub struct EncryptedItemBlob { + pub ciphertext: Vec, + pub nonce: Vec, +} + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum ItemCryptoError { + #[error("Encryption failed")] + EncryptionFailed, + #[error("Decryption failed — ciphertext invalid or tampered")] + DecryptionFailed, + #[error("Invalid key material")] + InvalidKey, + #[error("Invalid key length: expected {expected} bytes, got {got} bytes")] + InvalidKeyLength { expected: u64, got: u64 }, + #[error("{0}")] + Other(String), +} + +impl From for ItemCryptoError { + fn from(value: CryptoError) -> Self { + match value { + CryptoError::EncryptionFailed => Self::EncryptionFailed, + CryptoError::DecryptionFailed => Self::DecryptionFailed, + CryptoError::InvalidKey => Self::InvalidKey, + CryptoError::InvalidKeyLength { expected, got } => Self::InvalidKeyLength { + expected: expected as u64, + got: got as u64, + }, + other => Self::Other(format!("{other}")), + } + } +} + +#[derive(uniffi::Object)] +pub struct ItemManager; + +#[uniffi::export] +impl ItemManager { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub fn create_new_item_key(&self) -> ItemKey { + ItemKey::generate_random() + } + + pub fn encrypt_item_data( + &self, + item_key: ItemKey, + data: Vec, + aad: Vec, + ) -> Result { + let aad = ItemDataAad(aad); + let ct = item_key.encrypt_data(&data, &aad)?; + Ok(EncryptedItemBlob { + ciphertext: ct.ciphertext().to_vec(), + nonce: ct.nonce_bytes().to_vec(), + }) + } + + pub fn decrypt_item_data( + &self, + item_key: ItemKey, + blob: EncryptedItemBlob, + aad: Vec, + ) -> Result, ItemCryptoError> { + let aad = ItemDataAad(aad); + let ciphertext = AeadCiphertext::::from_parts_bytes(blob.ciphertext, &blob.nonce); + Ok(item_key.decrypt_data(&ciphertext, &aad)?) + } +} diff --git a/rust/rust-code/bindings/src/key_derivation.rs b/rust/rust-code/bindings/src/key_derivation.rs new file mode 100644 index 00000000..f19abf8f --- /dev/null +++ b/rust/rust-code/bindings/src/key_derivation.rs @@ -0,0 +1,71 @@ +use lib::crypto::TryDeriveFrom; +use lib::crypto::error::CryptoError; +use lib::crypto::keys::RootKEK; +use lib::crypto::primitive::argon2::MIN_SALT_LEN; +use lib::crypto::random::random_bytes; +use std::sync::Arc; + +const PASSWORD_DOMAIN: &[u8] = b"v1:kek/pwd"; +const RECOVERY_KEY_DOMAIN: &[u8] = b"v1:kek/rk"; +const SALT_LEN: usize = MIN_SALT_LEN; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum KeyDerivationError { + #[error("Key derivation failed: {0}")] + Failed(String), + #[error("{0}")] + Other(String), +} + +impl From for KeyDerivationError { + fn from(value: CryptoError) -> Self { + match value { + CryptoError::KdfError(msg) => Self::Failed(msg), + CryptoError::InvalidKeyLength { expected, got } => Self::Failed(format!( + "invalid key length: expected {expected}, got {got}" + )), + other => Self::Other(format!("{other}")), + } + } +} + +#[derive(uniffi::Object)] +pub struct KeyDeriver; + +#[uniffi::export] +impl KeyDeriver { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + /// Generate a fresh random salt suitable for password-based KEK derivation. + /// Persist this salt alongside the credential so the same KEK can be re-derived on login. + pub fn generate_salt(&self) -> Vec { + random_bytes::().to_vec() + } + + pub fn derive_root_kek_from_password( + &self, + password: String, + salt: Vec, + ) -> Result { + Ok(RootKEK::try_derive_from( + password.as_bytes(), + &salt, + PASSWORD_DOMAIN, + )?) + } + + pub fn derive_root_kek_from_recovery_key( + &self, + recovery_key: Vec, + salt: Vec, + ) -> Result { + Ok(RootKEK::try_derive_from( + &recovery_key, + &salt, + RECOVERY_KEY_DOMAIN, + )?) + } +} diff --git a/rust/rust-code/bindings/src/key_wrap.rs b/rust/rust-code/bindings/src/key_wrap.rs new file mode 100644 index 00000000..0357e35c --- /dev/null +++ b/rust/rust-code/bindings/src/key_wrap.rs @@ -0,0 +1,145 @@ +use lib::crypto::error::CryptoError; +use lib::crypto::item_key::{ItemAad, ItemKey}; +use lib::crypto::key::KeyMaterial; +use lib::crypto::keys::{AccountRootKey, RootKEK, VaultKey}; +use lib::crypto::primitive::wrap_key::{KeyWrapper as KeyWrapperTrait, WrappedKey}; +use lib::crypto::types::{UserId, VaultId}; +use std::sync::Arc; + +uniffi::custom_type!(RootKEK, Vec, { + remote, + try_lift: |bytes| { + RootKEK::try_from_bytes(&bytes) + .map_err(|e| uniffi::deps::anyhow::anyhow!("{e:?}")) + }, + lower: |key| key.as_bytes().to_vec(), +}); + +#[derive(uniffi::Record)] +pub struct WrappedKeyBlob { + pub ciphertext: Vec, + pub nonce: Vec, +} + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum KeyWrapError { + #[error("Key wrap failed")] + WrapFailed, + #[error("Key unwrap failed — wrong key or corrupted data")] + UnwrapFailed, + #[error("Invalid key material")] + InvalidKey, + #[error("Invalid key length: expected {expected} bytes, got {got} bytes")] + InvalidKeyLength { expected: u64, got: u64 }, + #[error("{0}")] + Other(String), +} + +impl From for KeyWrapError { + fn from(value: CryptoError) -> Self { + match value { + CryptoError::KeyWrapFailed => Self::WrapFailed, + CryptoError::KeyUnwrapFailed => Self::UnwrapFailed, + CryptoError::InvalidKey => Self::InvalidKey, + CryptoError::InvalidKeyLength { expected, got } => Self::InvalidKeyLength { + expected: expected as u64, + got: got as u64, + }, + other => Self::Other(format!("{other}")), + } + } +} + +fn wrap( + wrapper: &Wrapper, + target: &Target, + aad: &Wrapper::Aad, +) -> Result +where + Target: KeyMaterial, + Wrapper: KeyWrapperTrait, +{ + let wrapped = wrapper.wrap_key(target, aad)?; + Ok(WrappedKeyBlob { + ciphertext: wrapped.ciphertext().to_vec(), + nonce: wrapped.nonce_bytes().to_vec(), + }) +} + +fn unwrap( + wrapper: &Wrapper, + blob: &WrappedKeyBlob, + aad: &Wrapper::Aad, +) -> Result +where + Target: KeyMaterial, + Wrapper: KeyWrapperTrait, +{ + let wrapped = Wrapper::Wrapped::from_parts_bytes(blob.ciphertext.clone(), &blob.nonce); + Ok(wrapper.unwrap_key(&wrapped, aad)?) +} + +#[derive(uniffi::Object)] +pub struct KeyWrapper; + +#[uniffi::export] +impl KeyWrapper { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub fn wrap_account_root_key( + &self, + kek: RootKEK, + ark: AccountRootKey, + user_id: UserId, + ) -> Result { + wrap::(&kek, &ark, &user_id) + } + + pub fn unwrap_account_root_key( + &self, + kek: RootKEK, + wrapped: WrappedKeyBlob, + user_id: UserId, + ) -> Result { + unwrap::(&kek, &wrapped, &user_id) + } + + pub fn wrap_vault_key( + &self, + ark: AccountRootKey, + vault_key: VaultKey, + vault_id: VaultId, + ) -> Result { + wrap::(&ark, &vault_key, &vault_id) + } + + pub fn unwrap_vault_key( + &self, + ark: AccountRootKey, + wrapped: WrappedKeyBlob, + vault_id: VaultId, + ) -> Result { + unwrap::(&ark, &wrapped, &vault_id) + } + + pub fn wrap_item_key( + &self, + vault_key: VaultKey, + item_key: ItemKey, + aad: ItemAad, + ) -> Result { + wrap::(&vault_key, &item_key, &aad) + } + + pub fn unwrap_item_key( + &self, + vault_key: VaultKey, + wrapped: WrappedKeyBlob, + aad: ItemAad, + ) -> Result { + unwrap::(&vault_key, &wrapped, &aad) + } +} diff --git a/rust/rust-code/bindings/src/lib.rs b/rust/rust-code/bindings/src/lib.rs new file mode 100644 index 00000000..d15795ea --- /dev/null +++ b/rust/rust-code/bindings/src/lib.rs @@ -0,0 +1,8 @@ +mod account; +mod item; +mod key_derivation; +mod key_wrap; +mod passkey; +mod vault; + +uniffi::setup_scaffolding!(); diff --git a/rust/rust-code/bindings/src/passkey.rs b/rust/rust-code/bindings/src/passkey.rs new file mode 100644 index 00000000..886ab2bf --- /dev/null +++ b/rust/rust-code/bindings/src/passkey.rs @@ -0,0 +1,109 @@ +use lib::passkey::provider::{ProviderError, provide_passkey}; +use lib::passkey::registration::{ + KeyGoRegistrationResponse, RegistrationError, get_exclusion_list, register_passkey, +}; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum PasskeyError { + #[error("invalid json format")] + InvalidJsonFormat, + #[error("invalid url")] + InvalidDomain, + #[error("webauthn error: {reason}")] + Webauthn { reason: String }, + #[error("key codec error: {reason}")] + KeyCodec { reason: String }, +} + +impl From for PasskeyError { + fn from(value: RegistrationError) -> Self { + match value { + RegistrationError::InvalidJsonFormat => Self::InvalidJsonFormat, + RegistrationError::InvalidDomain => Self::InvalidDomain, + RegistrationError::WebauthnError(e) => Self::Webauthn { + reason: format!("{e:?}"), + }, + RegistrationError::KeyEncodeError(e) => Self::KeyCodec { + reason: format!("{e:?}"), + }, + } + } +} + +impl From for PasskeyError { + fn from(value: ProviderError) -> Self { + match value { + ProviderError::InvalidJsonFormat => Self::InvalidJsonFormat, + ProviderError::InvalidDomain => Self::InvalidDomain, + ProviderError::WebauthnError(e) => Self::Webauthn { + reason: format!("{e:?}"), + }, + ProviderError::PasskeyDecodeError(e) => Self::KeyCodec { + reason: format!("{e:?}"), + }, + } + } +} + +#[derive(uniffi::Record)] +pub struct RegistrationResponse { + pub response: String, + pub user_name: String, + pub user_display_name: String, + pub credential_id: Vec, + pub private_key: Vec, + pub rp: String, +} + +impl From for RegistrationResponse { + fn from(value: KeyGoRegistrationResponse) -> Self { + Self { + response: value.response, + user_name: value.user_name, + user_display_name: value.user_display_name, + credential_id: value.credential_id, + private_key: value.private_key, + rp: value.rp, + } + } +} + +#[derive(uniffi::Object)] +pub struct RustPasskey; + +#[uniffi::export(async_runtime = "tokio")] +impl RustPasskey { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub async fn register( + &self, + json_request: String, + ) -> Result { + register_passkey(&json_request) + .await + .map(Into::into) + .map_err(Into::into) + } + + pub async fn excluded_credentials( + &self, + json_request: String, + ) -> Result>, PasskeyError> { + get_exclusion_list(&json_request).await.map_err(Into::into) + } + + pub async fn authenticate( + &self, + json_request: String, + passkey: Vec, + client_data_hash: Option>, + ) -> Result { + provide_passkey(&json_request, &passkey, client_data_hash) + .await + .map_err(Into::into) + } +} diff --git a/rust/rust-code/bindings/src/vault.rs b/rust/rust-code/bindings/src/vault.rs new file mode 100644 index 00000000..9d35ad02 --- /dev/null +++ b/rust/rust-code/bindings/src/vault.rs @@ -0,0 +1,17 @@ +use lib::crypto::VaultKey; +use std::sync::Arc; + +#[derive(uniffi::Object)] +pub struct VaultManager; + +#[uniffi::export] +impl VaultManager { + #[uniffi::constructor] + pub fn new() -> Arc { + Arc::new(Self) + } + + pub fn create_new_vault_key(&self) -> VaultKey { + VaultKey::generate_random() + } +} diff --git a/rust/rust-code/bindings/uniffi-bindgen.rs b/rust/rust-code/bindings/uniffi-bindgen.rs new file mode 100644 index 00000000..f6cff6cf --- /dev/null +++ b/rust/rust-code/bindings/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/rust/rust-code/bindings/uniffi.toml b/rust/rust-code/bindings/uniffi.toml new file mode 100644 index 00000000..cc019474 --- /dev/null +++ b/rust/rust-code/bindings/uniffi.toml @@ -0,0 +1,8 @@ +[bindings.kotlin] +package_name = "de.davisalessandro.keygo.rust" + +[bindings.kotlin.custom_types.Uuid] +type_name = "java.util.UUID" +imports = ["java.util.UUID"] +lift = "java.util.UUID.fromString({})" +lower = "{}.toString()" diff --git a/rust/rust-code/build.sh b/rust/rust-code/build.sh deleted file mode 100755 index f72dbdb0..00000000 --- a/rust/rust-code/build.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -cd -- "$(dirname -- "${BASH_SOURCE[0]}" )" -script_name=$(basename "$0") - -ensure_cargo_ndk() { - if command -v cargo-ndk >/dev/null 2>&1; then - return 0 - fi - - echo "[$script_name] cargo-ndk not found. Attempting install via 'cargo install cargo-ndk'..." - - if ! command -v cargo >/dev/null 2>&1; then - echo "[$script_name] Error: 'cargo' is not installed. Install Rust (e.g., via rustup) and re-run." >&2 - exit 1 - fi - - # Install cargo-ndk - if ! cargo install cargo-ndk; then - echo "[$script_name] Error: failed to install cargo-ndk." >&2 - exit 1 - fi - - # Ensure ~/.cargo/bin is in PATH for the current shell - CARGO_BIN_DIR="${CARGO_HOME:-$HOME/.cargo}/bin" - if [[ ":$PATH:" != *":$CARGO_BIN_DIR:"* ]]; then - export PATH="$CARGO_BIN_DIR:$PATH" - fi - - # Verify it’s available now - if ! command -v cargo-ndk >/dev/null 2>&1; then - echo "[$script_name] Error: cargo-ndk installed but not found in PATH. Add '$CARGO_BIN_DIR' to your PATH." >&2 - exit 1 - fi - - echo "[$script_name] cargo-ndk installed successfully: $(cargo-ndk --version)" -} - -ensure_android_rust_targets() { - local -a targets=(aarch64-linux-android armv7-linux-androideabi x86_64-linux-android) - - # Need rustup - if ! command -v rustup >/dev/null 2>&1; then - echo "[$script_name] Error: rustup is not installed. Install Rust (e.g., via rustup) and re-run." >&2 - exit 1 - fi - - local installed missing=() - installed="$(rustup target list --installed)" - - for t in "${targets[@]}"; do - if ! grep -qx "$t" <<<"$installed"; then - missing+=("$t") - fi - done - - if ((${#missing[@]})); then - echo "[$script_name] Installing missing Rust Android targets: ${missing[*]}" - # Install into the active toolchain (or specify one with: --toolchain ) - rustup target add "${missing[@]}" - else - echo "[$script_name] All requested Rust Android targets already installed: ${targets[*]}" - fi -} - -usage() { - cat << EOF -Usage:: $script_name [--ndk /path/to/android/and/root] [--min-platform INT_VALUE] - -Builds the rust code and and exports the libraries for each ABI in the right jniLibs/ folder. -This may install cargo-ndk and all required targets. - -Options: - --ndk PATH Tells the cargo-ndk tool where the Android NDK folder is situated - --min-platform INT_VALUE Tells the cargo-ndk tool what the min platform is - -h, --help Show this help -EOF -} - -ndk_folder="" -min_platform="" - -# Parse args -while [[ $# -gt 0 ]]; do - case "$1" in - --ndk) - [[ $# -ge 2 && ${2:0:1} != "-" ]] || { echo "Error: --ndk requires a value"; exit 1; } - ndk_folder="$2"; shift 2 ;; - - --min-platform) - [[ $# -ge 2 && ${2:0:1} != "-" ]] || { echo "Error: --min-platform requires a value"; exit 1; } - min_platform="$2"; shift 2 ;; - - -h|--help) - usage; exit 0 ;; - --) shift; break ;; - *) - echo "Unknown argument: $1" - usage; exit 1 ;; - esac -done - -ensure_cargo_ndk -ensure_android_rust_targets - -if [[ -n "$ndk_folder" ]]; then - echo "[$script_name] Setting ANDROID_NDK_HOME to $ndk_folder" - export ANDROID_NDK_HOME="$ndk_folder" -fi - -cmd=(cargo ndk -t armeabi-v7a -t arm64-v8a -t x86_64) -if [[ -n "$min_platform" ]]; then - cmd+=(--platform "$min_platform") -fi -cmd+=(-o ../src/main/jniLibs build --release) - -echo "[$script_name] Executing build command: ${cmd[*]}" - -exec "${cmd[@]}" \ No newline at end of file diff --git a/rust/rust-code/lib/Cargo.toml b/rust/rust-code/lib/Cargo.toml new file mode 100644 index 00000000..66de8b52 --- /dev/null +++ b/rust/rust-code/lib/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "lib" +version = "0.1.0" +edition = "2024" + +[dependencies] +aead = "0.6.0-rc.10" +aes-gcm-siv = "0.12.0-rc.3" +async-trait = "0.1.89" +ciborium = "0.2.2" +coset = "0.4.2" +ed25519-dalek = { version = "3.0.0-pre.6", features = ["rand_core"] } +passkey = "0.5.0" +passkey-authenticator = { version = "0.5.0", features = ["tokio", "testable"] } +# Needed so passkey JSON responses are serialized into base64 strings +passkey-types = { version = "0.5.0", features = ["serialize_bytes_as_base64_string"] } + +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +thiserror = "2.0.18" +url = "2.5.8" +zeroize = "1.8.2" +uuid = { version = "1.23.0", features = ["serde", "v4"] } +rand = { version = "0.10.0", features = ["sys_rng"] } +bcs = "0.2.0" +argon2 = "0.5.3" diff --git a/rust/rust-code/lib/src/crypto/error.rs b/rust/rust-code/lib/src/crypto/error.rs new file mode 100644 index 00000000..7e71444a --- /dev/null +++ b/rust/rust-code/lib/src/crypto/error.rs @@ -0,0 +1,37 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CryptoError { + #[error("AEAD encryption failed")] + EncryptionFailed, + #[error("AEAD decryption failed — ciphertext invalid or tampered")] + DecryptionFailed, + + #[error("Key wrap failed")] + KeyWrapFailed, + #[error("Key unwrap failed — wrong key or corrupted data")] + KeyUnwrapFailed, + + #[error("CBOR serialisation failed: {0}")] + CborError(String), + #[error("BCS serialisation failed: {0}")] + BCS(String), + + #[error("Signature verification failed")] + SignatureInvalid, + + #[error("Invalid key material")] + InvalidKey, + + #[error("HPKE encapsulation failed: {0}")] + HpkeEncapFailed(String), + #[error("HPKE decapsulation failed: {0}")] + HpkeDecapFailed(String), + + #[error("Key derivation failed: {0}")] + KdfError(String), + #[error("Invalid key length: expected {expected} bytes, got {got} bytes")] + InvalidKeyLength { expected: usize, got: usize }, +} + +pub type CryptoResult = Result; diff --git a/rust/rust-code/lib/src/crypto/key.rs b/rust/rust-code/lib/src/crypto/key.rs new file mode 100644 index 00000000..0f54a2f9 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/key.rs @@ -0,0 +1,14 @@ +use crate::crypto::error::CryptoResult; +use aead::{Aead, AeadCore, Key, KeyInit}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +pub trait KeyMaterial: Sized { + fn try_from_bytes(bytes: &[u8]) -> CryptoResult; + fn as_bytes(&self) -> &[u8]; +} + +pub trait AeadKey: KeyMaterial + Zeroize + ZeroizeOnDrop + Sized { + type Algorithm: AeadCore + Aead + KeyInit; + + fn key(&self) -> &Key; +} diff --git a/rust/rust-code/lib/src/crypto/keys/account_root_key.rs b/rust/rust-code/lib/src/crypto/keys/account_root_key.rs new file mode 100644 index 00000000..2bade0da --- /dev/null +++ b/rust/rust-code/lib/src/crypto/keys/account_root_key.rs @@ -0,0 +1,6 @@ +use crate::define_aead_key; +use aes_gcm_siv::Aes256GcmSiv; + +define_aead_key! { + random pub struct AccountRootKey(Aes256GcmSiv); +} diff --git a/rust/rust-code/lib/src/crypto/keys/item_key.rs b/rust/rust-code/lib/src/crypto/keys/item_key.rs new file mode 100644 index 00000000..082bc145 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/keys/item_key.rs @@ -0,0 +1,25 @@ +use crate::crypto::VaultKey; +use crate::crypto::primitive::aead_data::AeadEncryptor; +use crate::crypto::types::{ItemId, VaultId}; +use crate::{define_aead_key, define_wrap}; +use aes_gcm_siv::Aes256GcmSiv; +use serde::Serialize; + +define_aead_key! { + random pub struct ItemKey(Aes256GcmSiv); +} + +#[derive(Serialize)] +pub struct ItemAad { + pub item_id: ItemId, + pub vault_id: VaultId, +} + +#[derive(Serialize)] +pub struct ItemDataAad(pub Vec); + +define_wrap!(VaultKey => ItemKey, aad = ItemAad); + +impl AeadEncryptor for ItemKey { + type Aad = ItemDataAad; +} diff --git a/rust/rust-code/lib/src/crypto/keys/mod.rs b/rust/rust-code/lib/src/crypto/keys/mod.rs new file mode 100644 index 00000000..36c69381 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/keys/mod.rs @@ -0,0 +1,15 @@ +pub mod account_root_key; +pub mod item_key; +pub mod root_kek; +pub mod signing_key; +pub mod vault_key; + +use crate::crypto::error::CryptoResult; +pub use account_root_key::*; +pub use root_kek::*; +pub use signing_key::*; +pub use vault_key::*; + +pub trait TryDeriveFrom: Sized { + fn try_derive_from(source: T, salt: &[u8], domain: &[u8]) -> CryptoResult; +} diff --git a/rust/rust-code/lib/src/crypto/keys/root_kek.rs b/rust/rust-code/lib/src/crypto/keys/root_kek.rs new file mode 100644 index 00000000..e9773f04 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/keys/root_kek.rs @@ -0,0 +1,54 @@ +use crate::crypto::TryDeriveFrom; +use crate::crypto::error::CryptoResult; +use crate::crypto::key::KeyMaterial; +use crate::crypto::keys::account_root_key::AccountRootKey; +use crate::crypto::primitive::argon2::derive_argon2id; +use crate::crypto::types::UserId; +use crate::{define_aead_key, define_wrap}; +use aes_gcm_siv::Aes256GcmSiv; + +define_aead_key! { + /// ### Root Key Encryption Key + /// + /// Used to wrap the [AccountRootKey] (ARK). Derived on every login from either the user's + /// password or the user's recovery key via Argon2id. Never stored directly. + pub struct RootKEK(Aes256GcmSiv); +} + +define_wrap!(RootKEK => AccountRootKey, aad = UserId); // TODO: also include epoch: pw wrapped: pwd_cred_epoch, rk wrapped: rk_cred_epoch + +impl<'a> TryDeriveFrom<&'a [u8]> for RootKEK { + fn try_derive_from(source: &'a [u8], salt: &[u8], domain: &[u8]) -> CryptoResult { + let derived = derive_argon2id(source, salt, domain)?; + ::try_from_bytes(&derived) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SALT: &[u8] = &[6; 16]; + const DOMAIN: &[u8] = b"v1:kek/pwd"; + + #[test] + fn same_credential_same_password_same_key() { + let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); + let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); + assert_eq!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn different_salt_different_keys() { + let a = RootKEK::try_derive_from(b"hunter2", &[7; 16], DOMAIN).unwrap(); + let b = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); + assert_ne!(a.as_bytes(), b.as_bytes()); + } + + #[test] + fn different_domain_different_keys() { + let a = RootKEK::try_derive_from(b"hunter2", SALT, DOMAIN).unwrap(); + let b = RootKEK::try_derive_from(b"hunter2", SALT, b"v2:kek/pwd").unwrap(); + assert_ne!(a.as_bytes(), b.as_bytes()); + } +} diff --git a/rust/rust-code/lib/src/crypto/keys/signing_key.rs b/rust/rust-code/lib/src/crypto/keys/signing_key.rs new file mode 100644 index 00000000..be2490f5 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/keys/signing_key.rs @@ -0,0 +1,77 @@ +use crate::crypto::error::{CryptoError, CryptoResult}; +use crate::crypto::key::KeyMaterial; +use crate::crypto::primitive::wrap_key::KeyWrapper; +use ed25519_dalek::{SECRET_KEY_LENGTH, Signature, Signer, SigningKey}; +use rand::rand_core::UnwrapErr; +use rand::rngs::SysRng; +use std::marker::PhantomData; + +pub struct ScopedSigningKey +where + Wrapper: KeyWrapper, +{ + signing_key: SigningKey, + _wrapper: PhantomData Wrapper>, +} + +impl ScopedSigningKey +where + Wrapper: KeyWrapper, +{ + pub fn generate() -> Self { + Self { + signing_key: SigningKey::generate(&mut UnwrapErr(SysRng)), + _wrapper: PhantomData, + } + } + + pub fn unwrap_signing_key( + wrapped_key: &Wrapper::Wrapped, + aad: &Wrapper::Aad, + wrapper: &Wrapper, + ) -> CryptoResult { + let signing_key = wrapper.unwrap_key(wrapped_key, aad)?; + Ok(Self { + signing_key, + _wrapper: PhantomData, + }) + } + + pub fn wrapped_signing_key( + &self, + aad: &Wrapper::Aad, + wrapper: &Wrapper, + ) -> CryptoResult { + wrapper.wrap_key(&self.signing_key, aad) + } + + pub fn public_key_bytes(&self) -> [u8; 32] { + self.signing_key.verifying_key().to_bytes() + } + + pub fn sign(&self, message: &[u8]) -> Signature { + self.signing_key.sign(message) + } + + pub fn verify(&self, message: &[u8], signature: &Signature) -> CryptoResult<()> { + self.signing_key + .verify(message, signature) + .map_err(|_| CryptoError::SignatureInvalid) + } +} + +impl KeyMaterial for SigningKey { + fn try_from_bytes(bytes: &[u8]) -> CryptoResult { + let key = bytes + .try_into() + .map_err(|_| CryptoError::InvalidKeyLength { + expected: SECRET_KEY_LENGTH, + got: bytes.len(), + })?; + Ok(Self::from_bytes(&key)) + } + + fn as_bytes(&self) -> &[u8] { + self.as_bytes() + } +} diff --git a/rust/rust-code/lib/src/crypto/keys/vault_key.rs b/rust/rust-code/lib/src/crypto/keys/vault_key.rs new file mode 100644 index 00000000..3dbf3143 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/keys/vault_key.rs @@ -0,0 +1,10 @@ +use crate::crypto::keys::account_root_key::AccountRootKey; +use crate::crypto::types::VaultId; +use crate::{define_aead_key, define_wrap}; +use aes_gcm_siv::Aes256GcmSiv; + +define_aead_key! { + random pub struct VaultKey(Aes256GcmSiv); +} + +define_wrap!(AccountRootKey => VaultKey, aad = VaultId); //TODO: also include epoch: vault_key_epoch diff --git a/rust/rust-code/lib/src/crypto/macros.rs b/rust/rust-code/lib/src/crypto/macros.rs new file mode 100644 index 00000000..08af6873 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/macros.rs @@ -0,0 +1,92 @@ +#[macro_export] +macro_rules! define_wrap { + ($wrapper:ident => $key:path, aad = $aad:path) => { + impl $crate::crypto::primitive::wrap_key::KeyWrapper<$key> for $wrapper { + type Aad = $aad; + type Wrapped = $crate::crypto::primitive::wrap_key::AeadWrappedKey<$key, Self>; + } + }; +} + +#[macro_export] +macro_rules! define_scoped_signing_key { + (wrapper = $wrapper:ident, key = $key:ident, wrapped_key = $wrapped:ident, aad = $aad:path $(,)?) => { + pub type $key = $crate::crypto::keys::signing_key::ScopedSigningKey<$wrapper>; + pub type $wrapped = <$wrapper as $crate::crypto::primitive::wrap_key::KeyWrapper< + ::ed25519_dalek::SigningKey, + >>::Wrapped; + + $crate::define_wrap!($wrapper => ::ed25519_dalek::SigningKey, aad = $aad); + + impl $wrapper { + pub fn wrap_signing_key( + &self, + signing_key: $key, + aad: &$aad, + ) -> $crate::crypto::error::CryptoResult<$wrapped> { + signing_key.wrapped_signing_key(aad, self) + } + + pub fn unwrap_signing_key( + &self, + wrapped_key: &$wrapped, + aad: &$aad, + ) -> $crate::crypto::error::CryptoResult<$key> { + $key::unwrap_signing_key(wrapped_key, aad, self) + } + } + }; +} + +#[macro_export] +macro_rules! define_aead_key { + ( + $(#[$meta:meta])* + random $vis:vis struct $name:ident($algo:ident); + ) => { + $crate::define_aead_key! { + $(#[$meta])* + $vis struct $name($algo); + } + + impl $name { + pub fn generate_random() -> Self { + let key = <::aead::Key::<$algo> as ::aead::Generate>::generate(); + Self(key) + } + } + }; + + ( + $(#[$meta:meta])* + $vis:vis struct $name:ident($algo:ident); + ) => { + $(#[$meta])* + #[derive(::zeroize::Zeroize, ::zeroize::ZeroizeOnDrop)] + $vis struct $name(::aead::Key<$algo>); + + impl $crate::crypto::key::AeadKey for $name { + type Algorithm = $algo; + + fn key(&self) -> &::aead::Key { + &self.0 + } + } + + impl $crate::crypto::key::KeyMaterial for $name { + fn try_from_bytes(bytes: &[u8]) -> $crate::crypto::error::CryptoResult { + let key = + ::aead::Key::<$algo>::try_from(bytes).map_err(|_| $crate::crypto::error::CryptoError::InvalidKeyLength { + expected: <<$algo as ::aead::KeySizeUser>::KeySize as ::aead::array::typenum::Unsigned>::USIZE, + got: bytes.len(), + })?; + + Ok(Self(key)) + } + + fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } + } + }; +} diff --git a/rust/rust-code/lib/src/crypto/mod.rs b/rust/rust-code/lib/src/crypto/mod.rs new file mode 100644 index 00000000..fa25f459 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/mod.rs @@ -0,0 +1,10 @@ +pub mod error; +pub mod key; +pub mod keys; +mod macros; +pub mod primitive; +pub mod random; +pub mod types; + +pub use key::*; +pub use keys::*; diff --git a/rust/rust-code/lib/src/crypto/primitive/aead_data.rs b/rust/rust-code/lib/src/crypto/primitive/aead_data.rs new file mode 100644 index 00000000..d720aef8 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/primitive/aead_data.rs @@ -0,0 +1,167 @@ +use crate::crypto::error::{CryptoError, CryptoResult}; +use crate::crypto::key::AeadKey; +use aead::{Aead, Generate, KeyInit, Nonce, Payload}; +use serde::Serialize; + +pub struct AeadCiphertext +where + K: AeadKey, +{ + ciphertext: Vec, + nonce: Nonce, +} + +impl AeadCiphertext +where + K: AeadKey, +{ + pub fn from_parts(ciphertext: Vec, nonce: Nonce) -> Self { + Self { ciphertext, nonce } + } + + pub fn from_parts_bytes(ciphertext: Vec, nonce: &[u8]) -> Self { + Self::from_parts( + ciphertext, + Nonce::::try_from(nonce).expect("invalid nonce length"), + ) + } + + pub fn ciphertext(&self) -> &[u8] { + &self.ciphertext + } + + pub fn nonce(&self) -> &Nonce { + &self.nonce + } + + pub fn nonce_bytes(&self) -> &[u8] { + self.nonce.as_slice() + } +} + +pub trait AeadEncryptor: AeadKey { + type Aad: Serialize; + + fn encrypt_data(&self, data: &[u8], aad: &Self::Aad) -> CryptoResult> { + let aad_bytes = bcs::to_bytes(aad).map_err(|e| CryptoError::BCS(e.to_string()))?; + let cipher = Self::Algorithm::new(self.key()); + let nonce = Nonce::::generate(); + let ciphertext = cipher + .encrypt( + &nonce, + Payload { + msg: data, + aad: &aad_bytes, + }, + ) + .map_err(|_| CryptoError::EncryptionFailed)?; + + Ok(AeadCiphertext::from_parts(ciphertext, nonce)) + } + + fn decrypt_data( + &self, + ciphertext: &AeadCiphertext, + aad: &Self::Aad, + ) -> CryptoResult> { + let aad_bytes = bcs::to_bytes(aad).map_err(|e| CryptoError::BCS(e.to_string()))?; + let cipher = Self::Algorithm::new(self.key()); + cipher + .decrypt( + ciphertext.nonce(), + Payload { + msg: ciphertext.ciphertext(), + aad: &aad_bytes, + }, + ) + .map_err(|_| CryptoError::DecryptionFailed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::define_aead_key; + use aes_gcm_siv::Aes256GcmSiv; + use serde::Serialize; + + define_aead_key! { + random struct TestKey(Aes256GcmSiv); + } + + #[derive(Serialize)] + struct TestAad { + scope: &'static str, + } + + impl AeadEncryptor for TestKey { + type Aad = TestAad; + } + + #[test] + fn round_trip() { + let key = TestKey::generate_random(); + let aad = TestAad { scope: "rt" }; + let plaintext = b"hello world"; + + let ct = key.encrypt_data(plaintext, &aad).unwrap(); + let pt = key.decrypt_data(&ct, &aad).unwrap(); + + assert_eq!(pt, plaintext); + } + + #[test] + fn wrong_aad_fails() { + let key = TestKey::generate_random(); + let aad = TestAad { scope: "right" }; + let wrong = TestAad { scope: "wrong" }; + + let ct = key.encrypt_data(b"data", &aad).unwrap(); + assert!(matches!( + key.decrypt_data(&ct, &wrong), + Err(CryptoError::DecryptionFailed) + )); + } + + #[test] + fn wrong_key_fails() { + let key = TestKey::generate_random(); + let other = TestKey::generate_random(); + let aad = TestAad { scope: "k" }; + + let ct = key.encrypt_data(b"data", &aad).unwrap(); + assert!(matches!( + other.decrypt_data(&ct, &aad), + Err(CryptoError::DecryptionFailed) + )); + } + + #[test] + fn tampered_ciphertext_fails() { + let key = TestKey::generate_random(); + let aad = TestAad { scope: "t" }; + + let ct = key.encrypt_data(b"data", &aad).unwrap(); + let mut bytes = ct.ciphertext().to_vec(); + bytes[0] ^= 0x01; + let tampered = AeadCiphertext::::from_parts_bytes(bytes, ct.nonce_bytes()); + + assert!(matches!( + key.decrypt_data(&tampered, &aad), + Err(CryptoError::DecryptionFailed) + )); + } + + #[test] + fn nonce_round_trip_via_bytes() { + let key = TestKey::generate_random(); + let aad = TestAad { scope: "n" }; + + let ct = key.encrypt_data(b"payload", &aad).unwrap(); + let rebuilt = + AeadCiphertext::::from_parts_bytes(ct.ciphertext().to_vec(), ct.nonce_bytes()); + + let pt = key.decrypt_data(&rebuilt, &aad).unwrap(); + assert_eq!(pt, b"payload"); + } +} diff --git a/rust/rust-code/lib/src/crypto/primitive/argon2.rs b/rust/rust-code/lib/src/crypto/primitive/argon2.rs new file mode 100644 index 00000000..5a2c4343 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/primitive/argon2.rs @@ -0,0 +1,83 @@ +use crate::crypto::error::{CryptoError, CryptoResult}; +use argon2::{Algorithm, Argon2, Params, Version}; + +/// Minimum salt length enforced for password-based derivation (RFC 9106 §3.1). +pub const MIN_SALT_LEN: usize = 16; + +/// Length of the derived key material (bytes). +pub const DERIVED_KEY_LEN: usize = 32; + +fn get_argon<'a>() -> Argon2<'a> { + let params = Params::new(64 * 1024, 3, 4, Some(DERIVED_KEY_LEN)).expect("valid default params"); + Argon2::new(Algorithm::Argon2id, Version::V0x13, params) +} + +/// Derive `DERIVED_KEY_LEN` bytes from a password using Argon2id. +/// +/// The salt must be caller-provided, at least `MIN_SALT_LEN` bytes, and persisted with the +/// credential so the same KEK can be re-derived on later logins. `domain` is mixed into the salt +/// as a label to separate otherwise-identical derivations (e.g. password-KEK vs recovery-KEK). +/// +/// The password is not zeroized by this function — the caller owns the password buffer and must +/// wrap it in `Zeroizing` / a secret type at the FFI boundary. +pub(crate) fn derive_argon2id( + password: &[u8], + salt: &[u8], + domain: &[u8], +) -> CryptoResult<[u8; DERIVED_KEY_LEN]> { + if password.is_empty() { + return Err(CryptoError::KdfError("empty password".into())); + } + if salt.len() < MIN_SALT_LEN { + return Err(CryptoError::KdfError(format!( + "salt too short: {} < {}", + salt.len(), + MIN_SALT_LEN, + ))); + } + + // Bind domain label into the salt so that changing that produces an independent key. + let mut full_salt = Vec::with_capacity(salt.len() + domain.len()); + full_salt.extend_from_slice(salt); + full_salt.extend_from_slice(domain); + + let argon2 = get_argon(); + + let mut out = [0u8; DERIVED_KEY_LEN]; + argon2 + .hash_password_into(password, &full_salt, out.as_mut_slice()) + .map_err(|e| CryptoError::KdfError(e.to_string()))?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PW: &[u8] = b"correct horse battery staple"; + const SALT: [u8; 16] = [0x11; 16]; + + #[test] + fn deterministic_for_same_inputs() { + let a = derive_argon2id(PW, &SALT, b"pwd").unwrap(); + let b = derive_argon2id(PW, &SALT, b"pwd").unwrap(); + assert_eq!(a, b); + } + + #[test] + fn different_salt_different_key() { + let a = derive_argon2id(PW, &SALT, b"pwd").unwrap(); + let b = derive_argon2id(PW, &[0x22; 16], b"pwd").unwrap(); + assert_ne!(a, b); + } + + #[test] + fn rejects_empty_password() { + assert!(derive_argon2id(b"", &SALT, b"pwd").is_err()); + } + + #[test] + fn rejects_short_salt() { + assert!(derive_argon2id(PW, &[0u8; 8], b"pwd").is_err()); + } +} diff --git a/rust/rust-code/lib/src/crypto/primitive/mod.rs b/rust/rust-code/lib/src/crypto/primitive/mod.rs new file mode 100644 index 00000000..aa628bdb --- /dev/null +++ b/rust/rust-code/lib/src/crypto/primitive/mod.rs @@ -0,0 +1,3 @@ +pub mod aead_data; +pub mod argon2; +pub mod wrap_key; diff --git a/rust/rust-code/lib/src/crypto/primitive/wrap_key.rs b/rust/rust-code/lib/src/crypto/primitive/wrap_key.rs new file mode 100644 index 00000000..626b971e --- /dev/null +++ b/rust/rust-code/lib/src/crypto/primitive/wrap_key.rs @@ -0,0 +1,278 @@ +use crate::crypto::error::{CryptoError, CryptoResult}; +use crate::crypto::key::{AeadKey, KeyMaterial}; +use aead::{Aead, Generate, KeyInit, Nonce, Payload}; +use serde::Serialize; +use std::marker::PhantomData; +use zeroize::Zeroizing; + +pub trait WrappedKey: Sized +where + Target: KeyMaterial, + Wrapper: AeadKey, +{ + fn from_parts(ciphertext: Vec, nonce: Nonce) -> Self; + fn from_parts_bytes(ciphertext: Vec, nonce: &[u8]) -> Self; + fn ciphertext(&self) -> &[u8]; + fn nonce(&self) -> &Nonce; + fn nonce_bytes(&self) -> &[u8] { + self.nonce().as_slice() + } +} + +pub struct AeadWrappedKey +where + Target: KeyMaterial, + Wrapper: AeadKey, +{ + ciphertext: Vec, + nonce: Nonce, + _target: PhantomData Target>, +} + +impl WrappedKey for AeadWrappedKey +where + Target: KeyMaterial, + Wrapper: AeadKey, +{ + fn from_parts(ciphertext: Vec, nonce: Nonce) -> Self { + Self { + ciphertext, + nonce, + _target: PhantomData, + } + } + + fn from_parts_bytes(ciphertext: Vec, nonce: &[u8]) -> Self { + Self::from_parts( + ciphertext, + Nonce::::try_from(nonce).expect("invalid nonce length"), + ) + } + + fn ciphertext(&self) -> &[u8] { + &self.ciphertext + } + + fn nonce(&self) -> &Nonce { + &self.nonce + } +} + +pub trait KeyWrapper: AeadKey +where + Target: KeyMaterial, +{ + type Aad: Serialize; + type Wrapped: WrappedKey; + + fn wrap_key(&self, key_to_wrap: &Target, aad: &Self::Aad) -> CryptoResult { + let aad = bcs::to_bytes(aad).map_err(|e| CryptoError::BCS(e.to_string()))?; + wrap_bytes::(self.key(), key_to_wrap.as_bytes(), &aad) + } + + fn unwrap_key(&self, wrapped_key: &Self::Wrapped, aad: &Self::Aad) -> CryptoResult { + let aad = bcs::to_bytes(aad).map_err(|e| CryptoError::BCS(e.to_string()))?; + let unwrapped = unwrap_bytes::(self.key(), wrapped_key, &aad)?; + Target::try_from_bytes(&unwrapped) + } +} + +fn wrap_bytes( + key: &aead::Key, + key_to_wrap: &[u8], + aad: &[u8], +) -> CryptoResult +where + Target: KeyMaterial, + Wrapper: KeyWrapper, +{ + let cipher = Wrapper::Algorithm::new(key); + let nonce = Nonce::::generate(); + let ciphertext = cipher + .encrypt( + &nonce, + Payload { + msg: key_to_wrap, + aad, + }, + ) + .map_err(|_| CryptoError::KeyWrapFailed)?; + + Ok(Wrapper::Wrapped::from_parts(ciphertext, nonce)) +} + +fn unwrap_bytes( + key: &aead::Key, + wrapped_key: &Wrapper::Wrapped, + aad: &[u8], +) -> CryptoResult>> +where + Target: KeyMaterial, + Wrapper: KeyWrapper, +{ + let cipher = Wrapper::Algorithm::new(key); + let plaintext = cipher + .decrypt( + wrapped_key.nonce(), + Payload { + msg: wrapped_key.ciphertext(), + aad, + }, + ) + .map_err(|_| CryptoError::KeyUnwrapFailed)?; + + Ok(Zeroizing::new(plaintext)) +} + +#[cfg(test)] +mod tests { + use super::KeyWrapper; + use crate::crypto::error::{CryptoError, CryptoResult}; + use crate::crypto::key::{AeadKey, KeyMaterial}; + use aead::Key; + use aes_gcm_siv::Aes256GcmSiv; + use serde::Serialize; + use zeroize::{Zeroize, ZeroizeOnDrop}; + + #[derive(Serialize)] + struct TestAad { + context: &'static str, + } + + #[derive(Zeroize, ZeroizeOnDrop)] + struct TestWrappingKey(Key); + + struct TestWrappedSecret { + bytes: [u8; 32], + } + + type TestWrappedBlob = super::AeadWrappedKey; + + impl KeyMaterial for TestWrappingKey { + fn try_from_bytes(bytes: &[u8]) -> CryptoResult { + let key = Key::::try_from(bytes).map_err(|_| { + CryptoError::InvalidKeyLength { + expected: 32, + got: bytes.len(), + } + })?; + + Ok(Self(key)) + } + + fn as_bytes(&self) -> &[u8] { + self.0.as_slice() + } + } + + impl AeadKey for TestWrappingKey { + type Algorithm = Aes256GcmSiv; + + fn key(&self) -> &Key { + &self.0 + } + } + + impl KeyMaterial for TestWrappedSecret { + fn try_from_bytes(bytes: &[u8]) -> CryptoResult { + let bytes: [u8; 32] = bytes + .try_into() + .map_err(|_| CryptoError::InvalidKeyLength { + expected: 32, + got: bytes.len(), + })?; + if bytes[0] != 0xAA { + return Err(CryptoError::InvalidKey); + } + + Ok(Self { bytes }) + } + + fn as_bytes(&self) -> &[u8] { + &self.bytes + } + } + + impl KeyWrapper for TestWrappingKey { + type Aad = TestAad; + type Wrapped = TestWrappedBlob; + } + + fn test_wrapping_key(byte: u8) -> TestWrappingKey { + TestWrappingKey::try_from_bytes(&[byte; 32]).unwrap() + } + + fn test_wrapped_secret() -> TestWrappedSecret { + let mut bytes = [0x11; 32]; + bytes[0] = 0xAA; + TestWrappedSecret { bytes } + } + + fn invalid_wrapped_secret() -> TestWrappedSecret { + TestWrappedSecret { bytes: [0x11; 32] } + } + + #[test] + fn round_trip() { + let wrapping_key = test_wrapping_key(7); + let wrapped_secret = test_wrapped_secret(); + let aad = TestAad { + context: "round-trip", + }; + + let wrapped = wrapping_key.wrap_key(&wrapped_secret, &aad).unwrap(); + let unwrapped = wrapping_key.unwrap_key(&wrapped, &aad).unwrap(); + + assert_eq!(wrapped_secret.as_bytes(), unwrapped.as_bytes()); + } + + #[test] + fn wrong_wrapping_key_fails() { + let wrapping_key = test_wrapping_key(7); + let wrong_key = test_wrapping_key(8); + let wrapped_secret = test_wrapped_secret(); + let aad = TestAad { + context: "wrong-key", + }; + + let wrapped = wrapping_key.wrap_key(&wrapped_secret, &aad).unwrap(); + + assert!(matches!( + wrong_key.unwrap_key(&wrapped, &aad), + Err(CryptoError::KeyUnwrapFailed) + )); + } + + #[test] + fn wrong_aad_fails() { + let wrapping_key = test_wrapping_key(7); + let wrapped_secret = test_wrapped_secret(); + let correct_aad = TestAad { context: "correct" }; + let wrong_aad = TestAad { context: "wrong" }; + + let wrapped = wrapping_key + .wrap_key(&wrapped_secret, &correct_aad) + .unwrap(); + + assert!(matches!( + wrapping_key.unwrap_key(&wrapped, &wrong_aad), + Err(CryptoError::KeyUnwrapFailed) + )); + } + + #[test] + fn invalid_key_material_fails_after_decrypt() { + let wrapping_key = test_wrapping_key(7); + let invalid_secret = invalid_wrapped_secret(); + let aad = TestAad { + context: "invalid-material", + }; + + let wrapped = wrapping_key.wrap_key(&invalid_secret, &aad).unwrap(); + + assert!(matches!( + wrapping_key.unwrap_key(&wrapped, &aad), + Err(CryptoError::InvalidKey) + )); + } +} diff --git a/rust/rust-code/lib/src/crypto/random.rs b/rust/rust-code/lib/src/crypto/random.rs new file mode 100644 index 00000000..eea905fc --- /dev/null +++ b/rust/rust-code/lib/src/crypto/random.rs @@ -0,0 +1,10 @@ +use rand::Rng; +use rand::rand_core::UnwrapErr; +use rand::rngs::SysRng; + +/// Generate a random byte array of the specified length. +pub fn random_bytes() -> [u8; N] { + let mut buf = [0u8; N]; + UnwrapErr(&mut SysRng).fill_bytes(&mut buf); + buf +} diff --git a/rust/rust-code/lib/src/crypto/types.rs b/rust/rust-code/lib/src/crypto/types.rs new file mode 100644 index 00000000..fe90bfb4 --- /dev/null +++ b/rust/rust-code/lib/src/crypto/types.rs @@ -0,0 +1,5 @@ +use uuid::Uuid; + +pub type UserId = Uuid; +pub type VaultId = Uuid; +pub type ItemId = Uuid; diff --git a/rust/rust-code/lib/src/item/account.rs b/rust/rust-code/lib/src/item/account.rs new file mode 100644 index 00000000..7d6cf8e5 --- /dev/null +++ b/rust/rust-code/lib/src/item/account.rs @@ -0,0 +1,16 @@ +use crate::crypto::AccountRootKey; +use crate::crypto::types::UserId; + +pub struct Account { + pub id: UserId, + pub ark: AccountRootKey, +} + +impl Account { + pub fn generate_new() -> Self { + Self { + id: UserId::new_v4(), + ark: AccountRootKey::generate_random(), + } + } +} diff --git a/rust/rust-code/lib/src/item/create_account.rs b/rust/rust-code/lib/src/item/create_account.rs new file mode 100644 index 00000000..d665ca2e --- /dev/null +++ b/rust/rust-code/lib/src/item/create_account.rs @@ -0,0 +1,16 @@ +use crate::item::account::Account; +use crate::item::vault::Vault; + +pub struct CreateAccount { + pub account: Account, + pub default_vault: Vault, +} + +impl CreateAccount { + pub fn generate_new() -> Self { + Self { + account: Account::generate_new(), + default_vault: Vault::generate_new(), + } + } +} diff --git a/rust/rust-code/lib/src/item/mod.rs b/rust/rust-code/lib/src/item/mod.rs new file mode 100644 index 00000000..a7128dda --- /dev/null +++ b/rust/rust-code/lib/src/item/mod.rs @@ -0,0 +1,3 @@ +pub mod account; +pub mod create_account; +pub mod vault; diff --git a/rust/rust-code/lib/src/item/vault.rs b/rust/rust-code/lib/src/item/vault.rs new file mode 100644 index 00000000..738ee3d8 --- /dev/null +++ b/rust/rust-code/lib/src/item/vault.rs @@ -0,0 +1,16 @@ +use crate::crypto::VaultKey; +use crate::crypto::types::VaultId; + +pub struct Vault { + pub id: VaultId, + pub vault_key: VaultKey, +} + +impl Vault { + pub fn generate_new() -> Self { + Self { + id: VaultId::new_v4(), + vault_key: VaultKey::generate_random(), + } + } +} diff --git a/rust/rust-code/lib/src/lib.rs b/rust/rust-code/lib/src/lib.rs new file mode 100644 index 00000000..81984367 --- /dev/null +++ b/rust/rust-code/lib/src/lib.rs @@ -0,0 +1,4 @@ +pub mod crypto; +pub mod item; +pub mod passkey; +mod url; diff --git a/rust/rust-code/src/passkey/authenticator.rs b/rust/rust-code/lib/src/passkey/authenticator.rs similarity index 63% rename from rust/rust-code/src/passkey/authenticator.rs rename to rust/rust-code/lib/src/passkey/authenticator.rs index 4c4283ba..b2422567 100644 --- a/rust/rust-code/src/passkey/authenticator.rs +++ b/rust/rust-code/lib/src/passkey/authenticator.rs @@ -1,6 +1,7 @@ use passkey::authenticator::{Authenticator, CredentialStore, UserCheck, UserValidationMethod}; -use passkey::types::ctap2::{Aaguid, Ctap2Error}; use passkey::types::Passkey; +use passkey::types::ctap2::{Aaguid, Ctap2Error}; +use passkey_authenticator::UiHint; pub(crate) struct KeyGoUserValidation {} @@ -8,19 +9,16 @@ pub(crate) struct KeyGoUserValidation {} impl UserValidationMethod for KeyGoUserValidation { type PasskeyItem = Passkey; - #[allow(clippy::needless_lifetimes)] async fn check_user<'a>( &self, - _credential: Option<&'a Self::PasskeyItem>, - _presence: bool, - _verification: bool, + _hint: UiHint<'a, Self::PasskeyItem>, + presence: bool, + verification: bool, ) -> Result { - Ok( - UserCheck { - presence: true, - verification: true, - } - ) + Ok(UserCheck { + presence, + verification, + }) } fn is_presence_enabled(&self) -> bool { @@ -32,8 +30,10 @@ impl UserValidationMethod for KeyGoUserValidation { } } -pub(crate) fn keygo_authenticator(store: S) -> Authenticator { +pub(crate) fn keygo_authenticator( + store: S, +) -> Authenticator { let aaguid = Aaguid::new_empty(); Authenticator::new(aaguid, store, KeyGoUserValidation {}) -} \ No newline at end of file +} diff --git a/rust/rust-code/lib/src/passkey/keygo_passkey.rs b/rust/rust-code/lib/src/passkey/keygo_passkey.rs new file mode 100644 index 00000000..bd2030f6 --- /dev/null +++ b/rust/rust-code/lib/src/passkey/keygo_passkey.rs @@ -0,0 +1,114 @@ +use coset::{CborSerializable, CoseError, CoseKey}; +use passkey::types::Passkey; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Serialize, Deserialize)] +pub struct KeyGoPasskey { + key: Vec, + credential_id: Vec, + rp_id: String, + user_handle: Option>, + username: Option, + user_display_name: Option, + counter: Option, +} + +#[derive(Serialize, Deserialize)] +pub enum KeyGoPasskeyWire { + V1(KeyGoPasskey), +} + +#[derive(Debug, Error)] +pub enum PasskeyCodecError { + #[error("cose key error: {0:?}")] + Cose(CoseError), + #[error("cbor encode error: {0}")] + CborEncode(#[from] ciborium::ser::Error), + #[error("cbor decode error: {0}")] + CborDecode(#[from] ciborium::de::Error), +} + +impl TryFrom for KeyGoPasskey { + type Error = PasskeyCodecError; + + fn try_from(value: Passkey) -> Result { + let key = value.key.to_vec().map_err(PasskeyCodecError::Cose)?; + Ok(Self { + key, + credential_id: value.credential_id.to_vec(), + rp_id: value.rp_id, + user_handle: value.user_handle.map(|b| b.to_vec()), + username: value.username, + user_display_name: value.user_display_name, + counter: value.counter, + }) + } +} + +impl TryFrom for Passkey { + type Error = PasskeyCodecError; + + fn try_from(value: KeyGoPasskey) -> Result { + let key = CoseKey::from_slice(&value.key).map_err(PasskeyCodecError::Cose)?; + Ok(Self { + key, + credential_id: value.credential_id.into(), + rp_id: value.rp_id, + user_handle: value.user_handle.map(|b| b.into()), + username: value.username, + user_display_name: value.user_display_name, + counter: value.counter, + extensions: Default::default(), + }) + } +} + +pub fn to_bytes(passkey: Passkey) -> Result, PasskeyCodecError> { + let wire = KeyGoPasskeyWire::V1(KeyGoPasskey::try_from(passkey)?); + let mut buf = Vec::new(); + ciborium::into_writer(&wire, &mut buf)?; + Ok(buf) +} + +pub fn passkey_from_bytes(bytes: &[u8]) -> Result { + let wire: KeyGoPasskeyWire = ciborium::from_reader(bytes)?; + match wire { + KeyGoPasskeyWire::V1(kgo_pk) => Passkey::try_from(kgo_pk), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use coset::CoseKeyBuilder; + use coset::iana; + + fn sample_passkey() -> Passkey { + let key = CoseKeyBuilder::new_okp_key() + .algorithm(iana::Algorithm::EdDSA) + .build(); + Passkey { + key, + credential_id: vec![1, 2, 3, 4].into(), + rp_id: "example.com".to_string(), + user_handle: Some(vec![9, 9, 9].into()), + username: Some("alice".to_string()), + user_display_name: Some("Alice".to_string()), + counter: Some(42), + extensions: Default::default(), + } + } + + #[test] + fn roundtrip_preserves_fields() { + let original = sample_passkey(); + let bytes = to_bytes(original.clone()).expect("encode"); + let decoded = passkey_from_bytes(&bytes).expect("decode"); + + assert_eq!(decoded.credential_id, original.credential_id); + assert_eq!(decoded.rp_id, original.rp_id); + assert_eq!(decoded.user_handle, original.user_handle); + assert_eq!(decoded.counter, original.counter); + } +} diff --git a/rust/rust-code/lib/src/passkey/mod.rs b/rust/rust-code/lib/src/passkey/mod.rs new file mode 100644 index 00000000..13a70394 --- /dev/null +++ b/rust/rust-code/lib/src/passkey/mod.rs @@ -0,0 +1,4 @@ +mod authenticator; +pub mod keygo_passkey; +pub mod provider; +pub mod registration; diff --git a/rust/rust-code/src/passkey/provider.rs b/rust/rust-code/lib/src/passkey/provider.rs similarity index 56% rename from rust/rust-code/src/passkey/provider.rs rename to rust/rust-code/lib/src/passkey/provider.rs index ad0c8c09..b565a49c 100644 --- a/rust/rust-code/src/passkey/provider.rs +++ b/rust/rust-code/lib/src/passkey/provider.rs @@ -1,13 +1,12 @@ use crate::passkey::authenticator::keygo_authenticator; -use crate::passkey::keygo_passkey::passkey_from_bytes; +use crate::passkey::keygo_passkey::{PasskeyCodecError, passkey_from_bytes}; use crate::url::sanitize_to_https_url; -use bincode::error::DecodeError; use passkey::client::{Client, WebauthnError}; -use passkey_types::webauthn::{CredentialRequestOptions, PublicKeyCredentialRequestOptions}; +use passkey::types::webauthn::{CredentialRequestOptions, PublicKeyCredentialRequestOptions}; use thiserror::Error; #[derive(Debug, Error)] -pub(crate) enum ProviderError { +pub enum ProviderError { #[error("invalid json format")] InvalidJsonFormat, #[error("invalid url")] @@ -15,12 +14,16 @@ pub(crate) enum ProviderError { #[error("webauthn error: {0:?}")] WebauthnError(WebauthnError), #[error("passkey decode error: {0:?}")] - PasskeyDecodeError(DecodeError), + PasskeyDecodeError(PasskeyCodecError), } -pub(crate) async fn provide_passkey(json_request: &str, passkey: &[u8], client_data_hash: Option>) -> Result { - let request_options: PublicKeyCredentialRequestOptions = serde_json::from_str(json_request) - .map_err(|_| ProviderError::InvalidJsonFormat)?; +pub async fn provide_passkey( + json_request: &str, + passkey: &[u8], + client_data_hash: Option>, +) -> Result { + let request_options: PublicKeyCredentialRequestOptions = + serde_json::from_str(json_request).map_err(|_| ProviderError::InvalidJsonFormat)?; let passkey = passkey_from_bytes(passkey).map_err(ProviderError::PasskeyDecodeError)?; @@ -30,13 +33,16 @@ pub(crate) async fn provide_passkey(json_request: &str, passkey: &[u8], client_d let domain = request_options.rp_id.as_deref().unwrap_or_default(); let domain = sanitize_to_https_url(domain).map_err(|_| ProviderError::InvalidDomain)?; - let options = CredentialRequestOptions { public_key: request_options }; + let options = CredentialRequestOptions { + public_key: request_options, + }; - let credential = client.authenticate(domain, options, client_data_hash) + let credential = client + .authenticate(domain, options, client_data_hash) .await .map_err(ProviderError::WebauthnError)?; let response_json = serde_json::to_string(&credential).unwrap(); Ok(response_json) -} \ No newline at end of file +} diff --git a/rust/rust-code/lib/src/passkey/registration.rs b/rust/rust-code/lib/src/passkey/registration.rs new file mode 100644 index 00000000..56aa1133 --- /dev/null +++ b/rust/rust-code/lib/src/passkey/registration.rs @@ -0,0 +1,76 @@ +use crate::passkey::authenticator::keygo_authenticator; +use crate::passkey::keygo_passkey::{PasskeyCodecError, to_bytes}; +use crate::passkey::registration::RegistrationError::{ + InvalidDomain, InvalidJsonFormat, KeyEncodeError, +}; +use crate::url::sanitize_to_https_url; +use passkey::client::{Client, DefaultClientData, WebauthnError}; +use passkey::types::webauthn::{CredentialCreationOptions, PublicKeyCredentialCreationOptions}; +use thiserror::Error; + +pub struct KeyGoRegistrationResponse { + pub response: String, + pub user_name: String, + pub user_display_name: String, + pub credential_id: Vec, + pub private_key: Vec, + pub rp: String, +} + +#[derive(Debug, Error)] +pub enum RegistrationError { + #[error("invalid json format")] + InvalidJsonFormat, + #[error("invalid url")] + InvalidDomain, + #[error("webauthn error: {0:?}")] + WebauthnError(WebauthnError), + #[error("key encode error: {0:?}")] + KeyEncodeError(PasskeyCodecError), +} + +pub async fn get_exclusion_list(json_request: &str) -> Result>, RegistrationError> { + let creation_options: PublicKeyCredentialCreationOptions = + serde_json::from_str(json_request).map_err(|_| InvalidJsonFormat)?; + + let list = creation_options.exclude_credentials.unwrap_or_default(); + let ids = list.iter().map(|desc| desc.id.clone().into()).collect(); + + Ok(ids) +} + +pub async fn register_passkey( + json_request: &str, +) -> Result { + let creation_options: PublicKeyCredentialCreationOptions = + serde_json::from_str(json_request).map_err(|_| InvalidJsonFormat)?; + + let domain = creation_options.rp.id.as_deref().unwrap_or_default(); + let user_name = creation_options.rp.name.clone(); + let user_display_name = creation_options.user.display_name.clone(); + + let authenticator = keygo_authenticator(None); + let mut client = Client::new(authenticator); + + let domain = sanitize_to_https_url(domain).map_err(|_| InvalidDomain)?; + let options = CredentialCreationOptions { + public_key: creation_options, + }; + let pub_key_credential = client + .register(domain, options, DefaultClientData) + .await + .map_err(RegistrationError::WebauthnError)?; + let response_json = serde_json::to_string(&pub_key_credential).unwrap(); + + let response = client.authenticator().store().clone().unwrap(); + + let credential_id = response.credential_id.clone().into(); + Ok(KeyGoRegistrationResponse { + response: response_json, + user_name, + user_display_name, + credential_id, + rp: response.rp_id.clone(), + private_key: to_bytes(response).map_err(KeyEncodeError)?, + }) +} diff --git a/rust/rust-code/src/url.rs b/rust/rust-code/lib/src/url.rs similarity index 74% rename from rust/rust-code/src/url.rs rename to rust/rust-code/lib/src/url.rs index f6afdffc..c1388616 100644 --- a/rust/rust-code/src/url.rs +++ b/rust/rust-code/lib/src/url.rs @@ -34,7 +34,10 @@ pub fn sanitize_to_https_url(input: &str) -> Result { if let Some(colon) = input.find(':') { // Check only well-known colon-only schemes to avoid misclassifying host:port. let maybe_scheme = input[..colon].to_ascii_lowercase(); - if matches!(maybe_scheme.as_str(), "mailto" | "data" | "javascript" | "file" | "blob" | "about") { + if matches!( + maybe_scheme.as_str(), + "mailto" | "data" | "javascript" | "file" | "blob" | "about" + ) { return Err(UrlSanitizeError::NonHttpScheme(maybe_scheme)); } } @@ -64,28 +67,26 @@ mod tests { #[test] fn test_sanitize_to_https_url() { - let test_map = HashMap::from( - [ - ("", false), - (" ", false), - ("ftp://example.com", false), - ("http://example.com", true), - ("https://example.com", true), - ("wss://example.com", false), - ("mailto:foo@bar", false), - ("data:text/plain,hi", false), - ("javascript:alert(1)", false), - ("file:///etc/hosts", false), - ("file://etc/hosts", false), - ("//example.com:3000/a", true), - ("HTTPS://Example.Com", true), - ("example.com", true), - ("example.com:3000", true), - ] - ); + let test_map = HashMap::from([ + ("", false), + (" ", false), + ("ftp://example.com", false), + ("http://example.com", true), + ("https://example.com", true), + ("wss://example.com", false), + ("mailto:foo@bar", false), + ("data:text/plain,hi", false), + ("javascript:alert(1)", false), + ("file:///etc/hosts", false), + ("file://etc/hosts", false), + ("//example.com:3000/a", true), + ("HTTPS://Example.Com", true), + ("example.com", true), + ("example.com:3000", true), + ]); for (url, valid) in test_map { assert_eq!(sanitize_to_https_url(url).is_ok(), valid); } } -} \ No newline at end of file +} diff --git a/rust/rust-code/src/lib.rs b/rust/rust-code/src/lib.rs deleted file mode 100644 index d8015b9b..00000000 --- a/rust/rust-code/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod passkey; -mod url; \ No newline at end of file diff --git a/rust/rust-code/src/passkey/ffi.rs b/rust/rust-code/src/passkey/ffi.rs deleted file mode 100644 index 9edf6d86..00000000 --- a/rust/rust-code/src/passkey/ffi.rs +++ /dev/null @@ -1,133 +0,0 @@ -#![allow(non_snake_case)] - -use crate::passkey::provider::provide_passkey; -use crate::passkey::registration::{get_exclusion_list, register_passkey}; -use jni::objects::{JByteArray, JClass, JObject, JString, JValueGen}; -use jni::sys::{jobject, jobjectArray}; -use jni::JNIEnv; -use tokio::runtime::Builder; - -static KOTLIN_MODEL_CLASS: &str = "de/davis/keygo/rust/passkey/model/KeyGoRegistrationResponse"; - -#[unsafe(no_mangle)] -pub extern "system" fn Java_de_davis_keygo_rust_passkey_PasskeyManager_registerPasskey( - mut env: JNIEnv, - _cls: JClass, - request_json: JString, -) -> jobject { - let request_json: String = env.get_string(&request_json).expect("Couldn't get java string!").into(); - - let rt = Builder::new_current_thread().build().unwrap(); - let response = rt.block_on(async move { - register_passkey(&request_json).await - }); - - let response = match response { - Ok(response) => response, - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", format!("Error occurred while registering passkey: {e}")); - return JObject::null().into_raw(); - } - }; - - let j_response_json = env.new_string(response.response()).expect("Couldn't create java string!"); - let j_rp_id = env.new_string(response.rp()).expect("Couldn't create java string!"); - - let j_user_name = env.new_string(response.user_name()).expect("Couldn't create java string!"); - let j_user_display_name = env.new_string(response.user_display_name()).expect("Couldn't create java string!"); - - let j_cred_id = env.byte_array_from_slice(response.credential_id()).expect("Couldn't create java byte array!"); - let j_bytes = env.byte_array_from_slice(response.private_key()).expect("Couldn't create java byte array!"); - - let cls = env.find_class(KOTLIN_MODEL_CLASS).unwrap(); - let obj = env - .new_object( - cls, - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;[B[B)V", - &[ - JValueGen::Object(&j_response_json), - JValueGen::Object(&j_rp_id), - JValueGen::Object(&j_user_name), - JValueGen::Object(&j_user_display_name), - JValueGen::Object(&j_cred_id), - JValueGen::Object(&j_bytes) - ], - ) - .expect("new_object failed"); - - obj.into_raw() -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_de_davis_keygo_rust_passkey_PasskeyManager_getExcludedCredentials( - mut env: JNIEnv, - _cls: JClass, - request_json: JString, -) -> jobjectArray { - let request_json: String = env.get_string(&request_json).expect("Couldn't get java string!").into(); - - let rt = Builder::new_current_thread().build().unwrap(); - let response = rt.block_on(async move { - get_exclusion_list(&request_json).await - }); - - let response = match response { - Ok(response) => response, - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", format!("Error occurred while getting excluded credential list: {e}")); - return JObject::null().into_raw(); - } - }; - - let byte_array_class = env - .find_class("[B") - .expect("Failed to find byte[] class"); - - let array = env.new_object_array( - response.len() as i32, - byte_array_class, - JObject::null(), - ).expect("Failed to crate outer array"); - - for (i, bytes) in response.iter().enumerate() { - let inner = env.byte_array_from_slice(bytes).expect("Failed to byte array"); - env.set_object_array_element( - &array, - i as i32, - &inner, - ).expect("Failed to set inner array"); - } - - array.into_raw() -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_de_davis_keygo_rust_passkey_PasskeyManager_authenticatePasskey( - mut env: JNIEnv, - _cls: JClass, - request_json: JString, - passkey: JByteArray, - client_data_hash: JByteArray, -) -> jobject { - let request_json: String = env.get_string(&request_json).expect("Couldn't get java string!").into(); - let passkey: Vec = env.convert_byte_array(&passkey).expect("Couldn't get java byte array!"); - let client_data_hash: Vec = env.convert_byte_array(&client_data_hash).expect("Couldn't get java byte array!"); - - - let rt = Builder::new_current_thread().build().unwrap(); - let response = rt.block_on(async move { - provide_passkey(&request_json, &passkey, Some(client_data_hash)).await - }); - - let response = match response { - Ok(response) => response, - Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", format!("Error occurred while getting excluded credential list: {e}")); - return JObject::null().into_raw(); - } - }; - - let j_response_json = env.new_string(response).expect("Couldn't create java string!"); - - j_response_json.into_raw() -} \ No newline at end of file diff --git a/rust/rust-code/src/passkey/keygo_passkey.rs b/rust/rust-code/src/passkey/keygo_passkey.rs deleted file mode 100644 index b7601547..00000000 --- a/rust/rust-code/src/passkey/keygo_passkey.rs +++ /dev/null @@ -1,56 +0,0 @@ -use bincode::error::{DecodeError, EncodeError}; -use coset::{CborSerializable, CoseKey}; -use passkey::types::Passkey; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize)] -pub(crate) struct KeyGoPasskey { - key: Vec, - credential_id: Vec, - rp_id: String, - user_handle: Option>, - counter: Option, -} - -impl From for KeyGoPasskey { - fn from(value: Passkey) -> Self { - Self { - key: value.key.to_vec().unwrap(), - credential_id: value.credential_id.to_vec(), - rp_id: value.rp_id, - user_handle: value.user_handle.map(|b| b.to_vec()), - counter: value.counter, - } - } -} - -impl From for Passkey { - fn from(value: KeyGoPasskey) -> Self { - Self { - key: CoseKey::from_slice(&value.key).unwrap(), - credential_id: value.credential_id.into(), - rp_id: value.rp_id, - user_handle: value.user_handle.map(|b| b.into()), - counter: value.counter, - extensions: Default::default(), - } - } -} - -#[derive(Serialize, Deserialize)] -pub(crate) enum KeyGoPasskeyWire { - V1(KeyGoPasskey) -} - -pub(crate) fn to_bytes(passkey: Passkey) -> Result, EncodeError> { - let wire = KeyGoPasskeyWire::V1(KeyGoPasskey::from(passkey)); - bincode::serde::encode_to_vec(&wire, bincode::config::standard()) -} - -pub(crate) fn passkey_from_bytes(passkey: &[u8]) -> Result { - let (wire, _) = bincode::serde::decode_from_slice::(passkey, bincode::config::standard())?; - - match wire { - KeyGoPasskeyWire::V1(kgo_pk) => Ok(Passkey::from(kgo_pk)) - } -} \ No newline at end of file diff --git a/rust/rust-code/src/passkey/mod.rs b/rust/rust-code/src/passkey/mod.rs deleted file mode 100644 index 6994e54a..00000000 --- a/rust/rust-code/src/passkey/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod authenticator; -mod registration; -mod ffi; -mod keygo_passkey; -mod provider; \ No newline at end of file diff --git a/rust/rust-code/src/passkey/registration.rs b/rust/rust-code/src/passkey/registration.rs deleted file mode 100644 index e35bd98e..00000000 --- a/rust/rust-code/src/passkey/registration.rs +++ /dev/null @@ -1,101 +0,0 @@ -use crate::passkey::authenticator::keygo_authenticator; -use crate::passkey::keygo_passkey::to_bytes; -use crate::passkey::registration::RegistrationError::{InvalidDomain, InvalidJsonFormat, KeyEncodeError}; -use crate::url::sanitize_to_https_url; -use passkey::client::{Client, DefaultClientData, WebauthnError}; -use passkey::types::webauthn::{CredentialCreationOptions, PublicKeyCredentialCreationOptions}; -use thiserror::Error; - -pub(crate) struct KeyGoRegistrationResponse { - response: String, - user_name: String, - user_display_name: String, - credential_id: Vec, - private_key: Vec, - rp: String, -} - -impl KeyGoRegistrationResponse { - pub(crate) fn response(&self) -> &str { - &self.response - } - - pub(crate) fn user_name(&self) -> &str { - &self.user_name - } - - pub(crate) fn user_display_name(&self) -> &str { - &self.user_display_name - } - - pub(crate) fn rp(&self) -> &str { - &self.rp - } - - pub(crate) fn credential_id(&self) -> &[u8] { - &self.credential_id - } - - pub(crate) fn private_key(&self) -> &[u8] { - &self.private_key - } -} - -#[derive(Debug, Error)] -pub(crate) enum RegistrationError { - #[error("invalid json format")] - InvalidJsonFormat, - #[error("invalid url")] - InvalidDomain, - #[error("webauthn error: {0:?}")] - WebauthnError(WebauthnError), - #[error("key encode error: {0:?}")] - KeyEncodeError(bincode::error::EncodeError), -} - -pub(crate) async fn get_exclusion_list(json_request: &str) -> Result>, RegistrationError> { - let creation_options: PublicKeyCredentialCreationOptions = serde_json::from_str(json_request) - .map_err(|_| InvalidJsonFormat)?; - - let list = creation_options.exclude_credentials.unwrap_or_default(); - let ids = list.iter() - .map(|desc| desc.id.clone().into()) - .collect(); - - Ok(ids) -} - -pub(crate) async fn register_passkey(json_request: &str) -> Result { - let creation_options: PublicKeyCredentialCreationOptions = serde_json::from_str(json_request) - .map_err(|_| InvalidJsonFormat)?; - - let domain = creation_options.rp.id.as_deref().unwrap_or_default(); - let user_name = creation_options.rp.name.clone(); - let user_display_name = creation_options.user.display_name.clone(); - - let authenticator = keygo_authenticator(None); - let mut client = Client::new(authenticator); - - let domain = sanitize_to_https_url(domain).map_err(|_| InvalidDomain)?; - let options = CredentialCreationOptions { public_key: creation_options }; - let pub_key_credential = client.register(domain, options, DefaultClientData) - .await - .map_err(RegistrationError::WebauthnError)?; - let response_json = serde_json::to_string(&pub_key_credential).unwrap(); - - let response = client.authenticator().store() - .clone() - .unwrap(); - - let credential_id = response.credential_id.clone().into(); - let keygo_registration_response = KeyGoRegistrationResponse { - response: response_json, - user_name, - user_display_name, - credential_id, - rp: response.rp_id.clone(), - private_key: to_bytes(response).map_err(KeyEncodeError)?, - }; - - Ok(keygo_registration_response) -} \ No newline at end of file diff --git a/rust/src/main/jniLibs/arm64-v8a/libkeygo_rust.so b/rust/src/main/jniLibs/arm64-v8a/libkeygo_rust.so deleted file mode 100755 index 764c4512..00000000 Binary files a/rust/src/main/jniLibs/arm64-v8a/libkeygo_rust.so and /dev/null differ diff --git a/rust/src/main/jniLibs/armeabi-v7a/libkeygo_rust.so b/rust/src/main/jniLibs/armeabi-v7a/libkeygo_rust.so deleted file mode 100755 index 855e826f..00000000 Binary files a/rust/src/main/jniLibs/armeabi-v7a/libkeygo_rust.so and /dev/null differ diff --git a/rust/src/main/jniLibs/x86_64/libkeygo_rust.so b/rust/src/main/jniLibs/x86_64/libkeygo_rust.so deleted file mode 100755 index f976bdad..00000000 Binary files a/rust/src/main/jniLibs/x86_64/libkeygo_rust.so and /dev/null differ diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/account/AccountManager.kt b/rust/src/main/kotlin/de/davis/keygo/rust/account/AccountManager.kt new file mode 100644 index 00000000..3321197f --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/account/AccountManager.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.rust.account + +import de.davisalessandro.keygo.rust.AccountManagerInterface + +typealias AccountManager = AccountManagerInterface \ No newline at end of file diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt b/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt new file mode 100644 index 00000000..7cf421b5 --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/derive/KeyDeriver.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.rust.derive + +import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.KeyDerivationException +import de.davisalessandro.keygo.rust.KeyDeriverInterface +import de.davisalessandro.keygo.rust.RootKek + +typealias KeyDeriver = KeyDeriverInterface + +fun KeyDeriver.deriveRootKekFromPasswordWithResult( + password: String, + salt: ByteArray, +): Result = runCatching { + deriveRootKekFromPassword(password, salt) +}.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as KeyDerivationException) } +) \ No newline at end of file diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt b/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt new file mode 100644 index 00000000..4744b297 --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/di/RustModule.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.rust.di + +import de.davisalessandro.keygo.rust.AccountManager +import de.davisalessandro.keygo.rust.AccountManagerInterface +import de.davisalessandro.keygo.rust.ItemManager +import de.davisalessandro.keygo.rust.ItemManagerInterface +import de.davisalessandro.keygo.rust.KeyDeriver +import de.davisalessandro.keygo.rust.KeyDeriverInterface +import de.davisalessandro.keygo.rust.KeyWrapper +import de.davisalessandro.keygo.rust.KeyWrapperInterface +import de.davisalessandro.keygo.rust.RustPasskey +import de.davisalessandro.keygo.rust.RustPasskeyInterface +import de.davisalessandro.keygo.rust.VaultManager +import de.davisalessandro.keygo.rust.VaultManagerInterface +import org.koin.core.annotation.Configuration +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single + +@Module +@Configuration +object RustModule { + + @Single + internal fun providePasskeyManager(): RustPasskeyInterface = RustPasskey() + + @Single + internal fun provideAccountManager(): AccountManagerInterface = AccountManager() + + @Single + internal fun provideKeyWrapper(): KeyWrapperInterface = KeyWrapper() + + @Single + internal fun provideKeyDeriver(): KeyDeriverInterface = KeyDeriver() + + @Single + internal fun provideItemManager(): ItemManagerInterface = ItemManager() + + @Single + internal fun provideVaultManager(): VaultManagerInterface = VaultManager() +} \ No newline at end of file diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/item/ItemManager.kt b/rust/src/main/kotlin/de/davis/keygo/rust/item/ItemManager.kt new file mode 100644 index 00000000..9eb75486 --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/item/ItemManager.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.rust.item + +import de.davisalessandro.keygo.rust.ItemManagerInterface + +typealias ItemManager = ItemManagerInterface \ No newline at end of file diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt b/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt index 0d995966..a5443c93 100644 --- a/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt +++ b/rust/src/main/kotlin/de/davis/keygo/rust/passkey/PasskeyManager.kt @@ -1,55 +1,45 @@ package de.davis.keygo.rust.passkey import de.davis.keygo.core.util.Result -import de.davis.keygo.rust.passkey.model.KeyGoRegistrationResponse +import de.davisalessandro.keygo.rust.PasskeyException +import de.davisalessandro.keygo.rust.RegistrationResponse +import de.davisalessandro.keygo.rust.RustPasskeyInterface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -object PasskeyManager { - - init { - System.loadLibrary("keygo_rust") - } - - suspend fun authenticate( - requestJson: String, - passkey: ByteArray, - clientDataHash: ByteArray - ): Result = withContext(Dispatchers.Default) { - runCatching { - authenticatePasskey(requestJson, passkey, clientDataHash) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it) } - ) - } - - suspend fun register(requestJson: String): Result = - withContext(Dispatchers.Default) { - runCatching { - registerPasskey(requestJson) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it) } - ) - } - - suspend fun getExcludedCredentialIds(requestJson: String): Result, Throwable> = - withContext(Dispatchers.Default) { - runCatching { - getExcludedCredentials(requestJson) - }.fold( - onSuccess = { Result.Success(it) }, - onFailure = { Result.Failure(it) } - ) - } - - private external fun authenticatePasskey( - requestJson: String, - passkey: ByteArray, - clientDataHash: ByteArray - ): String - - private external fun registerPasskey(requestJson: String): KeyGoRegistrationResponse - private external fun getExcludedCredentials(requestJson: String): Array -} \ No newline at end of file +suspend fun RustPasskeyInterface.authenticateWithResult( + requestJson: String, + passkey: ByteArray, + clientDataHash: ByteArray +): Result = withContext(Dispatchers.Default) { + runCatching { + authenticate(requestJson, passkey, clientDataHash) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as PasskeyException) } + ) +} + +suspend fun RustPasskeyInterface.registerWithResult( + requestJson: String +): Result = withContext(Dispatchers.Default) { + runCatching { + register(requestJson) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as PasskeyException) } + ) +} + +suspend fun RustPasskeyInterface.getExcludedCredentialIds( + requestJson: String +): Result, PasskeyException> = withContext(Dispatchers.Default) { + runCatching { + excludedCredentials(requestJson) + }.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as PasskeyException) } + ) +} + +typealias PasskeyManager = RustPasskeyInterface \ No newline at end of file diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/passkey/model/KeyGoRegistrationResponse.kt b/rust/src/main/kotlin/de/davis/keygo/rust/passkey/model/KeyGoRegistrationResponse.kt deleted file mode 100644 index 8a11412a..00000000 --- a/rust/src/main/kotlin/de/davis/keygo/rust/passkey/model/KeyGoRegistrationResponse.kt +++ /dev/null @@ -1,10 +0,0 @@ -package de.davis.keygo.rust.passkey.model - -class KeyGoRegistrationResponse( - val responseJson: String, - val rpId: String, - val userName: String, - val userDisplayName: String, - val credentialId: ByteArray, - val privateKey: ByteArray -) \ No newline at end of file diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/vault/VaultManager.kt b/rust/src/main/kotlin/de/davis/keygo/rust/vault/VaultManager.kt new file mode 100644 index 00000000..150b5f2c --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/vault/VaultManager.kt @@ -0,0 +1,5 @@ +package de.davis.keygo.rust.vault + +import de.davisalessandro.keygo.rust.VaultManagerInterface + +typealias VaultManager = VaultManagerInterface \ No newline at end of file diff --git a/rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt b/rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt new file mode 100644 index 00000000..8bb59cec --- /dev/null +++ b/rust/src/main/kotlin/de/davis/keygo/rust/wrap/KeyWrapper.kt @@ -0,0 +1,80 @@ +package de.davis.keygo.rust.wrap + +import de.davis.keygo.core.util.Result +import de.davisalessandro.keygo.rust.AccountRootKey +import de.davisalessandro.keygo.rust.ItemAad +import de.davisalessandro.keygo.rust.ItemKey +import de.davisalessandro.keygo.rust.KeyWrapException +import de.davisalessandro.keygo.rust.KeyWrapperInterface +import de.davisalessandro.keygo.rust.RootKek +import de.davisalessandro.keygo.rust.VaultKey +import de.davisalessandro.keygo.rust.WrappedKeyBlob +import java.util.UUID + +typealias KeyWrapper = KeyWrapperInterface + +fun KeyWrapper.unwrapAccountRootKeyWithResult( + kek: RootKek, + wrapped: WrappedKeyBlob, + userId: UUID, +): Result = runCatching { + unwrapAccountRootKey(kek, wrapped, userId) +}.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as KeyWrapException) } +) + +fun KeyWrapper.unwrapVaultKeyWithResult( + ark: AccountRootKey, + wrapped: WrappedKeyBlob, + vaultId: UUID, +): Result = runCatching { + unwrapVaultKey(ark, wrapped, vaultId) +}.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as KeyWrapException) } +) + +fun KeyWrapper.wrapAccountRootKeyWithResult( + kek: RootKek, + ark: AccountRootKey, + userId: UUID, +): Result = runCatching { + wrapAccountRootKey(kek, ark, userId) +}.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as KeyWrapException) } +) + +fun KeyWrapper.wrapVaultKeyWithResult( + ark: AccountRootKey, + vaultKey: VaultKey, + vaultId: UUID, +): Result = runCatching { + wrapVaultKey(ark, vaultKey, vaultId) +}.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as KeyWrapException) } +) + +fun KeyWrapper.wrapItemKeyWithResult( + vaultKey: VaultKey, + itemKey: ItemKey, + aad: ItemAad, +): Result = runCatching { + wrapItemKey(vaultKey, itemKey, aad) +}.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as KeyWrapException) } +) + +fun KeyWrapper.unwrapItemKeyWithResult( + vaultKey: VaultKey, + wrapped: WrappedKeyBlob, + aad: ItemAad, +): Result = runCatching { + unwrapItemKey(vaultKey, wrapped, aad) +}.fold( + onSuccess = { Result.Success(it) }, + onFailure = { Result.Failure(it as KeyWrapException) } +) diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeAccountManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeAccountManager.kt new file mode 100644 index 00000000..6dae03e9 --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeAccountManager.kt @@ -0,0 +1,22 @@ +package de.davis.keygo.rust + +import de.davisalessandro.keygo.rust.Account +import de.davisalessandro.keygo.rust.AccountManagerInterface +import de.davisalessandro.keygo.rust.CreateAccount +import de.davisalessandro.keygo.rust.Vault +import java.security.SecureRandom +import java.util.UUID + +/** + * In-memory [AccountManagerInterface] for tests. Each [createAccount] call yields fresh random + * ARK/vault-key material and new UUIDs. + */ +class FakeAccountManager : AccountManagerInterface { + + override fun createAccount(): CreateAccount = CreateAccount( + account = Account(id = UUID.randomUUID(), ark = randomKey()), + defaultVault = Vault(id = UUID.randomUUID(), vaultKey = randomKey()), + ) + + private fun randomKey(): ByteArray = ByteArray(32).also { SecureRandom().nextBytes(it) } +} \ No newline at end of file diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt new file mode 100644 index 00000000..5b2b60a9 --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeItemManager.kt @@ -0,0 +1,63 @@ +package de.davis.keygo.rust + +import de.davisalessandro.keygo.rust.EncryptedItemBlob +import de.davisalessandro.keygo.rust.ItemCryptoException +import de.davisalessandro.keygo.rust.ItemKey +import de.davisalessandro.keygo.rust.ItemManagerInterface +import java.security.SecureRandom + +/** + * In-memory [ItemManagerInterface] for tests. + * + * Encryption XORs the plaintext with a stream derived from (item key, AAD, nonce). Decryption + * reproduces the same stream and must match a recorded plaintext — passing the wrong key or AAD + * throws [ItemCryptoException.DecryptionFailed], exercising the authentication-failure path. + */ +class FakeItemManager : ItemManagerInterface { + + private val record = mutableMapOf() + + override fun createNewItemKey(): ItemKey = + ByteArray(32).also { SecureRandom().nextBytes(it) } + + override fun encryptItemData( + itemKey: ItemKey, + data: ByteArray, + aad: ByteArray, + ): EncryptedItemBlob { + val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } + val ciphertext = xorStream(data, itemKey, aad, nonce) + record[Key(itemKey.toList(), ciphertext.toList(), aad.toList(), nonce.toList())] = data + return EncryptedItemBlob(ciphertext = ciphertext, nonce = nonce) + } + + override fun decryptItemData( + itemKey: ItemKey, + blob: EncryptedItemBlob, + aad: ByteArray, + ): ByteArray = record[Key(itemKey.toList(), blob.ciphertext.toList(), aad.toList(), blob.nonce.toList())] + ?: throw ItemCryptoException.DecryptionFailed() + + private fun xorStream( + data: ByteArray, + itemKey: ByteArray, + aad: ByteArray, + nonce: ByteArray, + ): ByteArray { + if (data.isEmpty()) return ByteArray(0) + return ByteArray(data.size) { i -> + val aadByte = if (aad.isEmpty()) 0 else aad[i % aad.size].toInt() + val mask = itemKey[i % itemKey.size].toInt() xor + aadByte xor + nonce[i % nonce.size].toInt() + (data[i].toInt() xor mask).toByte() + } + } + + private data class Key( + val itemKey: List, + val ciphertext: List, + val aad: List, + val nonce: List, + ) +} diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyDeriver.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyDeriver.kt new file mode 100644 index 00000000..94895d5c --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyDeriver.kt @@ -0,0 +1,34 @@ +package de.davis.keygo.rust + +import de.davisalessandro.keygo.rust.KeyDerivationException +import de.davisalessandro.keygo.rust.KeyDeriverInterface +import de.davisalessandro.keygo.rust.RootKek +import java.security.MessageDigest +import java.security.SecureRandom + +/** + * In-memory [KeyDeriverInterface] for tests. + * + * Derivation is deterministic (SHA-256 of password + salt), so a KEK derived for the same + * (password, salt) pair round-trips with [FakeKeyWrapper]. Set [failDerivation] to force the + * next call to throw [KeyDerivationException.Failed]. + */ +class FakeKeyDeriver : KeyDeriverInterface { + + var failDerivation: Boolean = false + + override fun deriveRootKekFromPassword(password: String, salt: ByteArray): RootKek { + if (failDerivation) throw KeyDerivationException.Failed("forced") + return digest(password.toByteArray() + salt) + } + + override fun deriveRootKekFromRecoveryKey(recoveryKey: ByteArray, salt: ByteArray): RootKek { + if (failDerivation) throw KeyDerivationException.Failed("forced") + return digest(recoveryKey + salt) + } + + override fun generateSalt(): ByteArray = ByteArray(16).also { SecureRandom().nextBytes(it) } + + private fun digest(input: ByteArray): ByteArray = + MessageDigest.getInstance("SHA-256").digest(input) +} diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt new file mode 100644 index 00000000..3a0fb6d8 --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeKeyWrapper.kt @@ -0,0 +1,106 @@ +package de.davis.keygo.rust + +import de.davisalessandro.keygo.rust.AccountRootKey +import de.davisalessandro.keygo.rust.ItemAad +import de.davisalessandro.keygo.rust.ItemKey +import de.davisalessandro.keygo.rust.KeyWrapException +import de.davisalessandro.keygo.rust.KeyWrapperInterface +import de.davisalessandro.keygo.rust.RootKek +import de.davisalessandro.keygo.rust.VaultKey +import de.davisalessandro.keygo.rust.WrappedKeyBlob +import java.security.SecureRandom +import java.util.UUID + +/** + * In-memory [KeyWrapperInterface] for tests. + * + * Wrapping XORs the plaintext key with a stream derived from (outer key, id, nonce) so that + * wrap/unwrap round-trips correctly when the same outer key and id are supplied. Unwrapping + * with a different outer key or id yields garbage — every `unwrap*` call throws + * [KeyWrapException.UnwrapFailed] when the result does not match a recorded ciphertext, which + * is sufficient to exercise the wrong-password / wrong-key paths in use case tests. + * + * Set [failUnwrapItemForId] to force [unwrapItemKey] to throw the supplied exception whenever + * it is called for an item whose id matches the recorded id. + */ +class FakeKeyWrapper : KeyWrapperInterface { + + var failUnwrapItemForId: Pair? = null + + private val wrapRecord = mutableMapOf, List, UUID>, ByteArray>() + + override fun wrapAccountRootKey( + kek: RootKek, + ark: AccountRootKey, + userId: UUID, + ): WrappedKeyBlob = wrap(outerKey = kek, innerKey = ark, id = userId) + + override fun unwrapAccountRootKey( + kek: RootKek, + wrapped: WrappedKeyBlob, + userId: UUID, + ): AccountRootKey = unwrap(outerKey = kek, wrapped = wrapped, id = userId) + + override fun wrapVaultKey( + ark: AccountRootKey, + vaultKey: VaultKey, + vaultId: UUID, + ): WrappedKeyBlob = wrap(outerKey = ark, innerKey = vaultKey, id = vaultId) + + override fun unwrapVaultKey( + ark: AccountRootKey, + wrapped: WrappedKeyBlob, + vaultId: UUID, + ): VaultKey = unwrap(outerKey = ark, wrapped = wrapped, id = vaultId) + + override fun wrapItemKey( + vaultKey: VaultKey, + itemKey: ItemKey, + aad: ItemAad, + ): WrappedKeyBlob = wrap(outerKey = vaultKey, innerKey = itemKey, id = aadId(aad)) + + override fun unwrapItemKey( + vaultKey: VaultKey, + wrapped: WrappedKeyBlob, + aad: ItemAad, + ): ItemKey { + failUnwrapItemForId?.let { (failingItemId, error) -> + if (aad.itemId == failingItemId) throw error + } + return unwrap(outerKey = vaultKey, wrapped = wrapped, id = aadId(aad)) + } + + private fun aadId(aad: ItemAad): UUID = + UUID( + aad.itemId.mostSignificantBits xor aad.vaultId.mostSignificantBits, + aad.itemId.leastSignificantBits xor aad.vaultId.leastSignificantBits, + ) + + private fun wrap(outerKey: ByteArray, innerKey: ByteArray, id: UUID): WrappedKeyBlob { + val nonce = ByteArray(12).also { SecureRandom().nextBytes(it) } + val ciphertext = xorStream(innerKey, outerKey, id, nonce) + wrapRecord[Triple(outerKey.toList(), ciphertext.toList(), id)] = innerKey + return WrappedKeyBlob(ciphertext = ciphertext, nonce = nonce) + } + + private fun unwrap(outerKey: ByteArray, wrapped: WrappedKeyBlob, id: UUID): ByteArray { + val recorded = wrapRecord[Triple(outerKey.toList(), wrapped.ciphertext.toList(), id)] + ?: throw KeyWrapException.UnwrapFailed() + return recorded + } + + private fun xorStream( + innerKey: ByteArray, + outerKey: ByteArray, + id: UUID, + nonce: ByteArray, + ): ByteArray { + val idBytes = id.toString().toByteArray() + return ByteArray(innerKey.size) { i -> + val mask = outerKey[i % outerKey.size].toInt() xor + idBytes[i % idBytes.size].toInt() xor + nonce[i % nonce.size].toInt() + (innerKey[i].toInt() xor mask).toByte() + } + } +} diff --git a/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeVaultManager.kt b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeVaultManager.kt new file mode 100644 index 00000000..8142ae67 --- /dev/null +++ b/rust/src/testFixtures/kotlin/de/davis/keygo/rust/FakeVaultManager.kt @@ -0,0 +1,14 @@ +package de.davis.keygo.rust + +import de.davisalessandro.keygo.rust.VaultKey +import de.davisalessandro.keygo.rust.VaultManagerInterface +import java.security.SecureRandom + +/** + * In-memory [VaultManagerInterface] for tests. Generates a random 32-byte key on each call so + * successive calls produce distinct keys, mirroring the real implementation. + */ +class FakeVaultManager : VaultManagerInterface { + override fun createNewVaultKey(): VaultKey = + ByteArray(32).also { SecureRandom().nextBytes(it) } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index fb568839..012a4c0e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,5 +42,6 @@ include(":feature:item:create") include(":feature:totp") include(":feature:item:view") include(":core:identity") +include(":feature:vault") include(":feature:auth") include(":feature:autofill")