fix(vault): исправил шифрование, meta Yandex и enc-meta при первом открытии

Remember key после encrypt, мягкий auto-open в UnlockManager,
StorageMetaLoadState без затирания meta на сетевых ошибках,
фильтр storages в YandexVault и создание .enc-meta при FileNotFound.
This commit is contained in:
2026-05-21 11:05:14 +03:00
parent da8b970078
commit 467ed64426
10 changed files with 95 additions and 25 deletions

View File

@@ -58,11 +58,17 @@ class UnlockManager(
allStorages.removeAt(allStorages.size - 1) allStorages.removeAt(allStorages.size - 1)
allStorages.add(encStorage) allStorages.add(encStorage)
} }
catch (_: Exception) { catch (e: WallencException.Storage.IncorrectKey) {
// ключ не подошёл
keysToRemove.add(key) keysToRemove.add(key)
allStorages.removeAt(allStorages.size - 1) allStorages.removeAt(allStorages.size - 1)
} }
catch (_: WallencException.Storage.EncInfoMissing) {
keysToRemove.add(key)
allStorages.removeAt(allStorages.size - 1)
}
catch (_: Exception) {
allStorages.removeAt(allStorages.size - 1)
}
} }
keymapRepository.delete(*keysToRemove.toTypedArray()) // удалить мёртвые ключи keymapRepository.delete(*keysToRemove.toTypedArray()) // удалить мёртвые ключи
_openedStorages.value = map.toMap() _openedStorages.value = map.toMap()
@@ -104,6 +110,21 @@ class UnlockManager(
return UUID(bb.long, bb.long) return UUID(bb.long, bb.long)
} }
override suspend fun rememberKey(storage: IStorage, key: EncryptKey) = withContext(ioDispatcher) {
mutex.withLock {
val encInfo = storage.metaInfo.value.encInfo ?: throw WallencException.Storage.EncInfoMissing()
if (!Encryptor.checkKey(key, encInfo)) {
throw WallencException.Storage.IncorrectKey()
}
keymapRepository.add(
StorageKeyMap(
sourceUuid = storage.uuid,
key = key,
),
)
}
}
override suspend fun open( override suspend fun open(
storage: IStorage, storage: IStorage,
key: EncryptKey, key: EncryptKey,

View File

@@ -3,6 +3,7 @@ package com.github.nullptroma.wallenc.domain.vault.storages.common
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo import com.github.nullptroma.wallenc.domain.common.impl.CommonStorageMetaInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.errors.WallencException import com.github.nullptroma.wallenc.domain.errors.WallencException
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
@@ -39,6 +40,10 @@ abstract class BaseStorage(
final override val metaInfo: StateFlow<IStorageMetaInfo> final override val metaInfo: StateFlow<IStorageMetaInfo>
get() = _metaInfo get() = _metaInfo
private val _metaLoadState = MutableStateFlow(StorageMetaLoadState.Loading)
final override val metaLoadState: StateFlow<StorageMetaLoadState>
get() = _metaLoadState
final override val size: StateFlow<Long?> final override val size: StateFlow<Long?>
get() = accessor.size get() = accessor.size
@@ -71,32 +76,39 @@ abstract class BaseStorage(
} }
private suspend fun readMetaInfo() = withContext(ioDispatcher) { private suspend fun readMetaInfo() = withContext(ioDispatcher) {
val meta = try { val (meta, state) = loadMetaFromDisk()
accessor.openReadSystemFile(metaInfoFileName).use { input -> _metaInfo.value = meta
val bytes = input.readBytes() _metaLoadState.value = state
}
private suspend fun loadMetaFromDisk(): Pair<IStorageMetaInfo, StorageMetaLoadState> {
return try {
val bytes = accessor.openReadSystemFile(metaInfoFileName).use { it.readBytes() }
when { when {
bytes.isEmpty() -> { bytes.isEmpty() -> {
val default = CommonStorageMetaInfo() val default = CommonStorageMetaInfo()
updateMetaInfo(default) updateMetaInfo(default)
default default to StorageMetaLoadState.Ready
} }
else -> try { else -> try {
jackson.readValue(bytes, CommonStorageMetaInfo::class.java) jackson.readValue(bytes, CommonStorageMetaInfo::class.java) to StorageMetaLoadState.Ready
} catch (_: Exception) { } catch (_: Exception) {
// Битый JSON — не перезаписываем файл на диске CommonStorageMetaInfo() to StorageMetaLoadState.Unavailable
CommonStorageMetaInfo()
}
} }
} }
} catch (_: WallencException.Storage.FileNotFound) { } catch (_: WallencException.Storage.FileNotFound) {
val default = CommonStorageMetaInfo() val default = CommonStorageMetaInfo()
updateMetaInfo(default) updateMetaInfo(default)
default default to StorageMetaLoadState.Ready
} catch (_: Exception) { } catch (_: Exception) {
// Сеть/IO — оставляем дефолт в памяти, существующий файл не трогаем CommonStorageMetaInfo() to StorageMetaLoadState.Unavailable
CommonStorageMetaInfo() }
}
private suspend fun requireMetaReady() {
if (_metaLoadState.value != StorageMetaLoadState.Ready) {
throw WallencException.Storage.NotAvailable()
} }
_metaInfo.value = meta
} }
private suspend fun updateMetaInfo(meta: IStorageMetaInfo) = withContext(ioDispatcher) { private suspend fun updateMetaInfo(meta: IStorageMetaInfo) = withContext(ioDispatcher) {
@@ -108,6 +120,7 @@ abstract class BaseStorage(
} }
final override suspend fun rename(newName: String) = withContext(ioDispatcher) { final override suspend fun rename(newName: String) = withContext(ioDispatcher) {
requireMetaReady()
val cur = metaInfo.value val cur = metaInfo.value
updateMetaInfo( updateMetaInfo(
CommonStorageMetaInfo( CommonStorageMetaInfo(
@@ -118,6 +131,7 @@ abstract class BaseStorage(
} }
final override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = withContext(ioDispatcher) { final override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = withContext(ioDispatcher) {
requireMetaReady()
val cur = metaInfo.value val cur = metaInfo.value
updateMetaInfo( updateMetaInfo(
CommonStorageMetaInfo( CommonStorageMetaInfo(

View File

@@ -1,5 +1,7 @@
package com.github.nullptroma.wallenc.domain.vault.storages.encrypt package com.github.nullptroma.wallenc.domain.vault.storages.encrypt
import com.github.nullptroma.wallenc.domain.errors.WallencException
import com.github.nullptroma.wallenc.domain.vault.storages.common.readSystemFileBytesOrEmpty
import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosed import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosed
import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosing import com.github.nullptroma.wallenc.domain.vault.utils.CloseHandledStreamExtension.Companion.onClosing
import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory import com.github.nullptroma.wallenc.domain.common.impl.CommonDirectory
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.time.Instant import java.time.Instant
@@ -272,7 +275,11 @@ class EncryptedStorageAccessor(
override suspend fun openReadSystemFile(name: String): InputStream = scope.run { override suspend fun openReadSystemFile(name: String): InputStream = scope.run {
val path = Path(systemHiddenDirName, name).pathString val path = Path(systemHiddenDirName, name).pathString
try {
openRead(path) openRead(path)
} catch (_: WallencException.Storage.FileNotFound) {
ByteArrayInputStream(ByteArray(0))
}
} }
override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run { override suspend fun openWriteSystemFile(name: String): OutputStream = scope.run {
@@ -284,7 +291,7 @@ class EncryptedStorageAccessor(
} }
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> { override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> {
val bytes = openReadSystemFile(SYNC_JOURNAL_FILENAME).use { it.readBytes() } val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_JOURNAL_FILENAME) }
if (bytes.isEmpty()) { if (bytes.isEmpty()) {
return emptyList() return emptyList()
} }
@@ -316,7 +323,7 @@ class EncryptedStorageAccessor(
} }
override suspend fun readSyncLock(): StorageSyncLock? { override suspend fun readSyncLock(): StorageSyncLock? {
val bytes = openReadSystemFile(SYNC_LOCK_FILENAME).use { it.readBytes() } val bytes = readSystemFileBytesOrEmpty { openReadSystemFile(SYNC_LOCK_FILENAME) }
if (bytes.isEmpty()) { if (bytes.isEmpty()) {
return null return null
} }

View File

@@ -4,6 +4,7 @@ import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.YandexDiskA
import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository import com.github.nullptroma.wallenc.domain.vault.network.yandexdisk.repository.YandexDiskRepository
import com.github.nullptroma.wallenc.domain.vault.storages.yandex.YandexStorage import com.github.nullptroma.wallenc.domain.vault.storages.yandex.YandexStorage
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.vault.contract.CloudBrand import com.github.nullptroma.wallenc.vault.contract.CloudBrand
import com.github.nullptroma.wallenc.vault.contract.DescribedVault import com.github.nullptroma.wallenc.vault.contract.DescribedVault
@@ -132,7 +133,10 @@ class YandexVault(
if (attempt > 0) { if (attempt > 0) {
delay(STORAGE_INIT_RETRY_DELAY_MS * attempt) delay(STORAGE_INIT_RETRY_DELAY_MS * attempt)
} }
if (runCatching { storage.init() }.isSuccess) { if (
runCatching { storage.init() }.isSuccess &&
storage.metaLoadState.value == StorageMetaLoadState.Ready
) {
return storage return storage
} }
} }

View File

@@ -0,0 +1,7 @@
package com.github.nullptroma.wallenc.domain.datatypes
enum class StorageMetaLoadState {
Loading,
Ready,
Unavailable,
}

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.domain.interfaces package com.github.nullptroma.wallenc.domain.interfaces
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -14,6 +15,7 @@ sealed interface IStorageInfo {
val numberOfFiles: StateFlow<Int?> val numberOfFiles: StateFlow<Int?>
val isEmpty: Flow<Boolean?> val isEmpty: Flow<Boolean?>
val metaInfo: StateFlow<IStorageMetaInfo> val metaInfo: StateFlow<IStorageMetaInfo>
val metaLoadState: StateFlow<StorageMetaLoadState>
val isVirtualStorage: Boolean val isVirtualStorage: Boolean
} }

View File

@@ -16,6 +16,8 @@ interface IUnlockManager {
fun getOpenedStorageKey(uuid: UUID): EncryptKey? fun getOpenedStorageKey(uuid: UUID): EncryptKey?
suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean = true): IStorage suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean = true): IStorage
/** Сохранить ключ для auto-open без открытия виртуального storage. */
suspend fun rememberKey(storage: IStorage, key: EncryptKey)
suspend fun close(storage: IStorage) suspend fun close(storage: IStorage)
suspend fun close(uuid: UUID) suspend fun close(uuid: UUID)
} }

View File

@@ -46,6 +46,14 @@ class ManageStoragesEncryptionUseCase @Inject constructor(
} }
} }
suspend fun rememberStorageKey(storage: IStorageInfo, key: EncryptKey) {
if (storage is IStorage) {
unlockManager.rememberKey(storage, key)
return
}
throw IllegalStateException("Unsupported storage type")
}
suspend fun openStorage(storage: IStorageInfo, key: EncryptKey, rememberPassword: Boolean): IStorageInfo { suspend fun openStorage(storage: IStorageInfo, key: EncryptKey, rememberPassword: Boolean): IStorageInfo {
if (storage is IStorage) return unlockManager.open(storage, key, rememberPassword) if (storage is IStorage) return unlockManager.open(storage, key, rememberPassword)
throw IllegalStateException("Unsupported storage type") throw IllegalStateException("Unsupported storage type")

View File

@@ -1,6 +1,7 @@
package com.github.nullptroma.wallenc.usecases.fakes package com.github.nullptroma.wallenc.usecases.fakes
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.datatypes.StorageMetaLoadState
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
import com.github.nullptroma.wallenc.domain.interfaces.IStorage import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
@@ -23,6 +24,8 @@ class FakeStorage(
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0) override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isEmpty: Flow<Boolean?> = flowOf(true) override val isEmpty: Flow<Boolean?> = flowOf(true)
override val metaInfo: StateFlow<IStorageMetaInfo> = MutableStateFlow(meta) override val metaInfo: StateFlow<IStorageMetaInfo> = MutableStateFlow(meta)
override val metaLoadState: StateFlow<StorageMetaLoadState> =
MutableStateFlow(StorageMetaLoadState.Ready)
override val isVirtualStorage: Boolean = false override val isVirtualStorage: Boolean = false
override val accessor: IStorageAccessor = accessorImpl override val accessor: IStorageAccessor = accessorImpl

View File

@@ -24,6 +24,8 @@ class FakeUnlockManager : IUnlockManager {
override suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean): IStorage = storage override suspend fun open(storage: IStorage, key: EncryptKey, rememberPassword: Boolean): IStorage = storage
override suspend fun rememberKey(storage: IStorage, key: EncryptKey) = Unit
override suspend fun close(storage: IStorage) = Unit override suspend fun close(storage: IStorage) = Unit
override suspend fun close(uuid: UUID) = Unit override suspend fun close(uuid: UUID) = Unit