Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
937f64f
build(backup): add backup
OffRange Jun 12, 2026
b62da82
feat(backup): add backup hub UI
OffRange Jun 12, 2026
0b23781
feat(backup): add export wizard
OffRange Jun 12, 2026
362c05b
feat(backup): handle UI events in BackupHubViewModel
OffRange Jun 15, 2026
f8ba142
feat(backup): add steps
OffRange Jun 15, 2026
d28d0e9
fix(backup): skip passphrase for csv
OffRange Jun 15, 2026
8a96835
fix(backup): centralize continue button
OffRange Jun 15, 2026
d48a215
feat(backup): enhance UI
OffRange Jun 16, 2026
f87064d
wip(backup): checkpoint in-progress destination UI
OffRange Jun 16, 2026
14367a6
feat(backup): allow file and folder picker
OffRange Jun 17, 2026
31b1dbf
feat(backup): integrate password strength estimation in export wizard
OffRange Jun 17, 2026
238d9f5
refactor(backup): move ScheduleMode to standalone file
OffRange Jun 17, 2026
bcd9916
feat(security): allow optional authentication for KeyStore keys
OffRange Jun 17, 2026
87f26aa
feat(backup): set up WorkManager and DataStore infrastructure
OffRange Jun 17, 2026
c48510c
feat(backup): include destination URI in SelectDestinationState
OffRange Jun 17, 2026
e57ea83
refactor(backup): remove Export event from ExportWizardUiEvent
OffRange Jun 17, 2026
5663c6c
feat(backup): implement scheduler
OffRange Jun 17, 2026
8ecb7e6
feat(backup): restrict recurring schedule to encrypted formats
OffRange Jun 17, 2026
fad5bd0
Merge branch 'feat/backup-worker' into feat/backup-ui
OffRange Jun 17, 2026
022e931
fix(backup): hide continue button for SelectFormat
OffRange Jun 17, 2026
1206c42
feat(backup): store backup jobs for one-time backupss
OffRange Jun 18, 2026
d391614
feat(backup): surface export wizard failures via snackbar
OffRange Jun 18, 2026
796ebc4
refactor(backup): ExportDetails construction
OffRange Jun 18, 2026
48d1fde
refactor(backup): replace kdbx with json
OffRange Jun 24, 2026
4bf7c30
Feat/backup rust (#61)
OffRange Jun 24, 2026
34ecbdb
refactor(backup): rename wrappedPassphrase
OffRange Jun 24, 2026
88d5c43
refactor(backup): rename managers
OffRange Jun 24, 2026
7f48a9e
feat(backup): improve export data flow
OffRange Jul 17, 2026
70d364b
feat(backup): improve UI
OffRange Jul 22, 2026
ac8efe5
feat(backup): add CSV import with column mapping and vault targeting
OffRange Jul 24, 2026
7abf7a2
chore: replace chars
OffRange Jul 25, 2026
fc10580
feat(settings): merge backup entries into one hub entrance
OffRange Jul 25, 2026
d0cb411
feat(backup): carry the vault icon through JSON backups
OffRange Jul 25, 2026
ae39ea6
feat(backup): let the CSV import pick an icon for the vault it creates
OffRange Jul 25, 2026
ffff3a7
feat(backup): mirror name and icon state
OffRange Jul 25, 2026
88c853d
fix(backup): icons
OffRange Jul 25, 2026
446496f
feat(backup): UI/UX cleanup
OffRange Jul 26, 2026
779c706
fix(setting): single list item border radius
OffRange Jul 31, 2026
0cd5dcb
refactor(setting): improve exhausting when block
OffRange Jul 31, 2026
488b9f6
feat(backup): add retries exhausted reason and enhance backup resourc…
OffRange Jul 31, 2026
d14598c
refactor(tests): add comments to clarify test-only cryptographic values
OffRange Jul 31, 2026
1aadadc
docs(KeyId): clarify comment for biometric vault key usage
OffRange Jul 31, 2026
6ed2639
refactor(fakes): drop fake tests
OffRange Jul 31, 2026
643eb2c
refactor(worker): introduce worker id alias
OffRange Jul 31, 2026
81aa9ff
refactor(proto): outsource default serializer
OffRange Jul 31, 2026
6bb493b
fix(backup): backup list of websites
OffRange Jul 31, 2026
4317238
refactor(backup): move repository to repo package
OffRange Jul 31, 2026
961a771
refactor(backup): drop interface
OffRange Jul 31, 2026
2bf6403
fix(codeql): move suppressing comments into standalone line
OffRange Jul 31, 2026
f61f05c
fix: place buttons below each other on large dpi
OffRange Aug 1, 2026
0e54956
refactor: move to presentation package
OffRange Aug 1, 2026
1c6bde5
refactor: improve performance
OffRange Aug 1, 2026
cd23efe
refactor: move fakes
OffRange Aug 1, 2026
f119202
refactor: revert suppression comments
OffRange Aug 1, 2026
49dc8e7
fix: resolve backup destinations without a live folder grant
OffRange Aug 1, 2026
b03489a
Merge branch 'v2' into feat/backup-ui
OffRange Aug 1, 2026
97e1711
fix(rust): clippy
OffRange Aug 1, 2026
884f04f
chore(rust): update dependencies
OffRange Aug 1, 2026
272cd76
fix: merge
OffRange Aug 1, 2026
83affeb
chore(rust): update dependencies
OffRange Aug 1, 2026
5cc3e45
fix(settings): fix SettingsViewModelTest compile error after ObserveL…
OffRange Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ Password / Biometric
↓ derive / unlock
RootKek ───────────────────── never persisted
↓ unwrap
ARK (Account Root Key) ────── in-memory only (Session); wrapped in account_registry.pb
ARK (Account Root Key) ────── Session (in-memory); wrapped in account_registry.pb;
optionally escrowed in backup_ark_data.pb (see Backup Escrow)
↓ unwrap (one per vault)
VaultKey ──────────────────── wrapped in VaultEntity.keyInformation (Room)
↓ unwrap (one per item)
Expand All @@ -92,6 +93,30 @@ Password / Biometric
- **AAD** (`itemId + vaultId`) — bound to every ciphertext; prevents transplant attacks.
- **Rust FFI** (`de.davis.keygo.rust`) implements all wrap/unwrap/derive operations.

## Backup Escrow

A scheduled backup runs with no user present, so it cannot reach the ARK the normal way. Scheduling
one therefore escrows a second copy of the ARK, and the export passphrase alongside it, under
Keystore aliases that deliberately do **not** require user authentication (`KeyId.BackupArkKey`,
`KeyId.BackupPassphraseKey`; see `BackupArkUnlocker`).

This is the one place the "ARK is never readable without authenticating" rule is relaxed, so it
carries its own rules:

- The escrow exists **only while a job is scheduled**. `CleanupBackupResourcesUseCase` releases it
the moment no live job remains, and `reconcile()` sweeps up jobs the scheduler dropped without a
run. `BackupWorker.MAX_ATTEMPTS` bounds retries so a deferring job cannot hold it open forever.
- `reconcile()` runs once per process start (`BackupEscrowReconciler`, an eager Koin singleton), not
on entry to the backup screen. A dropped job produces no run to clean up after it, so the trigger
must not depend on the user navigating anywhere; process start bounds the escrow's stale lifetime
to a single process. Do not move it back behind a UI event.
- Both aliases set `setUnlockedDeviceRequired(true)` on API 28+. On API 26-27 that constraint does
not exist, so on those levels the escrow is readable whenever the process runs.
- A passphrase-sealed scheduled backup is not stronger than an ARK-sealed one: both keys sit under
the same auth-free policy.
- Do not widen the escrow's lifetime, its auth policy, or the set of callers that can read it
without explicit instruction.

## Sensitive Areas

- **Migration** — preserve backward compat, smallest safe change
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
## KeyGo v2 Your Secure Digital Vault
## KeyGo v2 - Your Secure Digital Vault

> [!CAUTION]
> This branch (v2) is under active development. For the stable v1 release, switch to
Expand All @@ -8,5 +8,5 @@ For further information, please see the [v1 branch](https://github.com/OffRange/

### License

Licensed under the GNU General Public License v3.0. This means KeyGo is free software you can
Licensed under the GNU General Public License v3.0. This means KeyGo is free software: you can
redistribute and/or modify it under GPLv3 terms. For full details, see the [LICENSE](LICENSE) file.
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ dependencies {
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.androidx.compose)
implementation(libs.koin.annotations)
implementation(libs.koin.androidx.workmanager)

implementation(libs.aboutlibraries.compose.m3)

Expand All @@ -116,6 +117,7 @@ dependencies {
implementation(projects.feature.creditCard)
implementation(projects.feature.autofill)
implementation(projects.feature.settings)
implementation(projects.feature.backup)
implementation(projects.migrationCreateAccess)

implementation(libs.androidx.core.ktx)
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/kotlin/de/davis/keygo/app/KeyGoApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package de.davis.keygo.app
import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.workmanager.koin.workManagerFactory
import org.koin.core.annotation.KoinApplication
import org.koin.plugin.module.dsl.startKoin

Expand All @@ -14,6 +15,7 @@ class KeyGoApplication : Application() {
startKoin<KeyGoApplication> {
androidLogger()
androidContext(this@KeyGoApplication)
workManagerFactory()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import de.davis.keygo.dashboard.presentation.DetailType
import de.davis.keygo.dashboard.presentation.dashboardGraph
import de.davis.keygo.feature.auth.presentation.AuthRoute
import de.davis.keygo.feature.auth.presentation.authGraph
import de.davis.keygo.feature.backup.presentation.BackupHubRoute
import de.davis.keygo.feature.backup.presentation.backupGraph
import de.davis.keygo.feature.settings.presentation.ChangePasswordRoute
import de.davis.keygo.feature.settings.presentation.settingsGraph
import de.davis.keygo.item.dialog.SelectItemContent
Expand Down Expand Up @@ -165,6 +167,7 @@ private fun App() {
settingsGraph(
onOpenChangePassword = { navController.navigate(ChangePasswordRoute) },
onShowLibraries = { navController.navigate(RouteDestination.Libraries) },
onOpenBackup = { navController.navigate(BackupHubRoute) },
onUp = { navController.navigateUp() },
)

Expand All @@ -185,6 +188,11 @@ private fun App() {
)
}
}

backupGraph(
navigateToDestination = navController::navigate,
navigateUp = { navController.navigateUp() },
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.datastore.dataStore
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 de.davis.keygo.core.util.data.serializer.DefaultProtoSerializer
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Configuration
import org.koin.core.annotation.Module
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ class CreateAccessUseCase(

// 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.
// write fails after this, the half-state is recoverable on retry, since `set` overwrites.
accountRepository.set(
Account(
id = accountHolder.account.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.room.Upsert
import de.davis.keygo.core.item.data.local.entity.CreditCardEntity
import de.davis.keygo.core.item.data.local.pojo.CreditCardProjection
import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.core.item.domain.alias.VaultId
import kotlinx.coroutines.flow.Flow

@Dao
Expand All @@ -22,4 +23,8 @@ internal interface CreditCardDao {
@Transaction
@Query("SELECT * FROM credit_card WHERE id = :id")
suspend fun getById(id: ItemId): CreditCardProjection?
}

@Transaction
@Query("SELECT * FROM credit_card WHERE id IN (SELECT id FROM item WHERE vault_id = :vaultId)")
suspend fun getByVault(vaultId: VaultId): List<CreditCardProjection>
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.room.Insert
import androidx.room.Query
import de.davis.keygo.core.item.data.local.entity.credential.PasskeyEntity
import de.davis.keygo.core.item.data.local.pojo.PasskeyMetadataPojo
import de.davis.keygo.core.item.domain.alias.ItemId

@Dao
internal interface PasskeyDao {
Expand All @@ -15,6 +16,9 @@ internal interface PasskeyDao {
@Query("SELECT * FROM passkey WHERE credential_id = :credentialId")
suspend fun getPasskey(credentialId: ByteArray): PasskeyEntity?

@Query("SELECT * FROM passkey WHERE login_id = :loginId")
suspend fun getPasskeysForLogin(loginId: ItemId): List<PasskeyEntity>

@Query("SELECT EXISTS (SELECT 1 FROM passkey WHERE credential_id IN (:credentialIds))")
suspend fun doesCredentialIdsExist(credentialIds: Set<ByteArray>): Boolean

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.core.item.domain.model.EncryptedPayload
import de.davis.keygo.core.item.domain.model.PasswordScore

// The primary key is shared with ItemEntity one ItemId identifies both the base item row and
// 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import androidx.room.withTransaction
import de.davis.keygo.core.item.data.local.dao.CreditCardDao
import de.davis.keygo.core.item.data.local.dao.ItemDao
import de.davis.keygo.core.item.data.local.datasource.ItemDatabase
import de.davis.keygo.core.item.data.local.pojo.CreditCardProjection
import de.davis.keygo.core.item.data.mapper.toCreditCardEntity
import de.davis.keygo.core.item.data.mapper.toData
import de.davis.keygo.core.item.data.mapper.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.CreditCard
import de.davis.keygo.core.item.domain.repository.CreditCardRepository
import de.davis.keygo.core.util.Result
Expand Down Expand Up @@ -40,4 +42,7 @@ internal class CreditCardRepositoryImpl(

override suspend fun getCreditCardById(itemId: ItemId): CreditCard? =
creditCardDao.getById(itemId)?.toDomain()
}

override suspend fun getCreditCardsByVault(vaultId: VaultId): List<CreditCard> =
creditCardDao.getByVault(vaultId).map(CreditCardProjection::toDomain)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package de.davis.keygo.core.item.data.repository

import de.davis.keygo.core.item.data.local.dao.PasskeyDao
import de.davis.keygo.core.item.data.local.entity.credential.PasskeyEntity
import de.davis.keygo.core.item.data.local.pojo.PasskeyMetadataPojo
import de.davis.keygo.core.item.data.mapper.toData
import de.davis.keygo.core.item.data.mapper.toDomain
import de.davis.keygo.core.item.domain.alias.ItemId
import de.davis.keygo.core.item.domain.model.Passkey
import de.davis.keygo.core.item.domain.model.PasskeyMetadata
import de.davis.keygo.core.item.domain.repository.PasskeyRepository
Expand All @@ -26,4 +28,7 @@ internal class PasskeyRepositoryImpl(

override suspend fun getPasskey(credentialId: ByteArray): Passkey? =
passkeyDao.getPasskey(credentialId)?.toDomain()

override suspend fun getPasskeysByLogin(loginId: ItemId): List<Passkey> =
passkeyDao.getPasskeysForLogin(loginId).map(PasskeyEntity::toDomain)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package de.davis.keygo.core.item.data.repository

import androidx.room.withTransaction
import de.davis.keygo.core.item.data.local.datasource.ItemDatabase
import de.davis.keygo.core.item.domain.TransactionRunner
import org.koin.core.annotation.Single

@Single
internal class RoomTransactionRunner(
private val database: ItemDatabase,
) : TransactionRunner {
override suspend fun <R> runInTransaction(block: suspend () -> R): R =
database.withTransaction { block() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package de.davis.keygo.core.item.domain

/**
* Runs a block of persistence work as a single atomic unit: either every write inside [block]
* commits, or none of them do. If the calling coroutine is cancelled or [block] throws, the
* transaction rolls back.
*
* Nested calls join the outermost transaction rather than opening a new one, so callers can safely
* compose operations that already run their own transactions internally.
*/
interface TransactionRunner {
suspend fun <R> runInTransaction(block: suspend () -> R): R
}
Original file line number Diff line number Diff line change
@@ -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.CreditCard
import de.davis.keygo.core.util.Result
import kotlinx.coroutines.flow.Flow
Expand All @@ -10,4 +11,5 @@ interface CreditCardRepository {

fun observeCreditCardById(itemId: ItemId): Flow<CreditCard?>
suspend fun getCreditCardById(itemId: ItemId): CreditCard?
suspend fun getCreditCardsByVault(vaultId: VaultId): List<CreditCard>
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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.Passkey
import de.davis.keygo.core.item.domain.model.PasskeyMetadata

Expand All @@ -11,4 +12,6 @@ interface PasskeyRepository {
suspend fun getPasskeysForRP(rpId: String): List<PasskeyMetadata>

suspend fun getPasskey(credentialId: ByteArray): Passkey?

suspend fun getPasskeysByLogin(loginId: ItemId): List<Passkey>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package de.davis.keygo.core.item.presentation

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
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.Surface
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.davis.keygo.core.item.domain.model.Vault

/**
* Every [Vault.Icon] as a toggle grid, one of which is [selected].
*
* Lives here rather than in a feature module because more than one flow creates vaults, and the
* picker has to look and behave the same in all of them. Callers supply their own heading.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun VaultIconPicker(
selected: Vault.Icon,
onSelect: (Vault.Icon) -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Vault.Icon.entries.forEach { icon ->
FilledTonalIconToggleButton(
checked = selected == icon,
onCheckedChange = { onSelect(icon) },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize()),
shapes = IconButtonDefaults.toggleableShapes(),
) {
Icon(
imageVector = icon.toImageVector(),
contentDescription = null,
)
}
}
}
}

@Preview
@Composable
private fun VaultIconPickerPreview() {
MaterialTheme {
Surface {
VaultIconPicker(selected = Vault.Icon.Work, onSelect = {})
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class ItemMapperTest {
keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()),
)

// Item.toData() ItemEntity
// Item.toData() -> ItemEntity

@Test
fun `Login toData maps id and vaultId`() {
Expand Down Expand Up @@ -80,7 +80,7 @@ class ItemMapperTest {
assertFalse((testLogin(pinned = false) as Item).toData().pinned)
}

// LightweightItem.toDomain() LiteItem.Concrete
// LightweightItem.toDomain() -> LiteItem.Concrete

@Test
fun `LightweightItem toDomain copies all fields`() {
Expand All @@ -100,7 +100,7 @@ class ItemMapperTest {
assertTrue(lite.pinned)
}

// LightweightItemSearchResult.toDomain() LiteItemSearchResult
// LightweightItemSearchResult.toDomain() -> LiteItemSearchResult

@Test
fun `LightweightItemSearchResult toDomain copies all fields`() {
Expand All @@ -126,7 +126,7 @@ class ItemMapperTest {
assertFalse(result.pinned)
}

// MovableItemPojo.toDomain() MovableItem
// MovableItemPojo.toDomain() -> MovableItem

@Test
fun `MovableItemPojo toDomain maps id`() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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.CreditCard
import de.davis.keygo.core.item.domain.repository.CreditCardRepository
import de.davis.keygo.core.util.Result
Expand Down Expand Up @@ -50,4 +51,7 @@ class FakeCreditCardRepository : CreditCardRepository {
store.map { it[itemId] }

override suspend fun getCreditCardById(itemId: ItemId): CreditCard? = store.value[itemId]

override suspend fun getCreditCardsByVault(vaultId: VaultId): List<CreditCard> =
store.value.values.filter { it.vaultId == vaultId }
}
Loading
Loading