Implement complete Android settings and recovery controls (#104)
This commit is contained in:
1 parent
06575363a2
commit
43f660ace3
34 files changed
+2718
-62
No files matched your search
@@ -291,6 +291,8 @@ def emulator(serial):
|
|||||||
raw.write_text(result)
|
raw.write_text(result)
|
||||||
raw.chmod(0o600)
|
raw.chmod(0o600)
|
||||||
raise RuntimeError("ARM emulator instrumentation failed (raw output retained locally only)")
|
raise RuntimeError("ARM emulator instrumentation failed (raw output retained locally only)")
|
||||||
|
# Release instrumentation-owned activities and native owners before real app starts.
|
||||||
|
command("shell", "am", "force-stop", package)
|
||||||
# No activity state or application data is deleted between process recreation runs.
|
# No activity state or application data is deleted between process recreation runs.
|
||||||
timings = []
|
timings = []
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ import androidx.compose.ui.test.assertHasClickAction
|
|||||||
import androidx.compose.ui.test.assertHeightIsAtLeast
|
import androidx.compose.ui.test.assertHeightIsAtLeast
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsSelected
|
import androidx.compose.ui.test.assertIsSelected
|
||||||
|
import androidx.compose.ui.test.hasTestTag
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performKeyInput
|
import androidx.compose.ui.test.performKeyInput
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.performScrollToNode
|
||||||
import androidx.compose.ui.test.performSemanticsAction
|
import androidx.compose.ui.test.performSemanticsAction
|
||||||
import androidx.compose.ui.test.pressKey
|
import androidx.compose.ui.test.pressKey
|
||||||
import androidx.compose.ui.unit.Density
|
import androidx.compose.ui.unit.Density
|
||||||
@@ -66,6 +68,7 @@ class AdaptiveShellTest {
|
|||||||
original.search,
|
original.search,
|
||||||
original.authentication,
|
original.authentication,
|
||||||
original.editor,
|
original.editor,
|
||||||
|
original.settings,
|
||||||
saved
|
saved
|
||||||
)
|
)
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
@@ -101,6 +104,9 @@ class AdaptiveShellTest {
|
|||||||
setup.armor = "public fixture armor"
|
setup.armor = "public fixture armor"
|
||||||
setup.token = "public fixture token"
|
setup.token = "public fixture token"
|
||||||
}
|
}
|
||||||
|
compose.onNodeWithTag(
|
||||||
|
"settings-list"
|
||||||
|
).performScrollToNode(hasTestTag("shell-open-appearance"))
|
||||||
compose.onNodeWithTag("shell-open-appearance").performClick()
|
compose.onNodeWithTag("shell-open-appearance").performClick()
|
||||||
compose.runOnIdle {
|
compose.runOnIdle {
|
||||||
assertEquals("", setup.passphrase)
|
assertEquals("", setup.passphrase)
|
||||||
@@ -118,7 +124,13 @@ class AdaptiveShellTest {
|
|||||||
compose.onNodeWithTag("shell-tab-Preferences").performClick()
|
compose.onNodeWithTag("shell-tab-Preferences").performClick()
|
||||||
compose.onNodeWithText("System default").assertIsDisplayed()
|
compose.onNodeWithText("System default").assertIsDisplayed()
|
||||||
compose.onNodeWithText("Back").performScrollTo().performClick()
|
compose.onNodeWithText("Back").performScrollTo().performClick()
|
||||||
|
compose.onNodeWithTag(
|
||||||
|
"settings-list"
|
||||||
|
).performScrollToNode(hasTestTag("shell-open-appearance"))
|
||||||
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
|
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
|
||||||
|
compose.onNodeWithTag(
|
||||||
|
"settings-list"
|
||||||
|
).performScrollToNode(hasTestTag("shell-open-appearance"))
|
||||||
compose.onNodeWithTag("shell-open-appearance").performClick()
|
compose.onNodeWithTag("shell-open-appearance").performClick()
|
||||||
compose.runOnIdle {
|
compose.runOnIdle {
|
||||||
assertEquals("Preferences", saved.get<String>("shell.tab"))
|
assertEquals("Preferences", saved.get<String>("shell.tab"))
|
||||||
@@ -141,6 +153,7 @@ class AdaptiveShellTest {
|
|||||||
original.search,
|
original.search,
|
||||||
original.authentication,
|
original.authentication,
|
||||||
original.editor,
|
original.editor,
|
||||||
|
original.settings,
|
||||||
SavedStateHandle(
|
SavedStateHandle(
|
||||||
saved.keys().associateWith {
|
saved.keys().associateWith {
|
||||||
saved.get<Any>(it)
|
saved.get<Any>(it)
|
||||||
@@ -162,6 +175,9 @@ class AdaptiveShellTest {
|
|||||||
compose.onNodeWithTag("shell-tab-Preferences").assertIsSelected()
|
compose.onNodeWithTag("shell-tab-Preferences").assertIsSelected()
|
||||||
compose.onNodeWithText("Dark").performScrollTo().assertIsDisplayed()
|
compose.onNodeWithText("Dark").performScrollTo().assertIsDisplayed()
|
||||||
compose.onNodeWithText("Back").performScrollTo().performClick()
|
compose.onNodeWithText("Back").performScrollTo().performClick()
|
||||||
|
compose.onNodeWithTag(
|
||||||
|
"settings-list"
|
||||||
|
).performScrollToNode(hasTestTag("shell-open-appearance"))
|
||||||
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
|
compose.onNodeWithTag("shell-open-appearance").assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +193,7 @@ class AdaptiveShellTest {
|
|||||||
original.search,
|
original.search,
|
||||||
original.authentication,
|
original.authentication,
|
||||||
original.editor,
|
original.editor,
|
||||||
|
original.settings,
|
||||||
saved
|
saved
|
||||||
)
|
)
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
@@ -219,6 +236,7 @@ class AdaptiveShellTest {
|
|||||||
original.search,
|
original.search,
|
||||||
original.authentication,
|
original.authentication,
|
||||||
original.editor,
|
original.editor,
|
||||||
|
original.settings,
|
||||||
SavedStateHandle(mapOf("shell.tab" to "Passwords"))
|
SavedStateHandle(mapOf("shell.tab" to "Passwords"))
|
||||||
)
|
)
|
||||||
compose.activity.runOnUiThread {
|
compose.activity.runOnUiThread {
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package de.rfc1437.ironstorage
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Parcel
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.test.assertHeightIsAtLeast
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertTextContains
|
||||||
|
import androidx.compose.ui.test.assertWidthIsAtLeast
|
||||||
|
import androidx.compose.ui.test.hasText
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.performScrollToNode
|
||||||
|
import androidx.compose.ui.test.performTextReplacement
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationState
|
||||||
|
import de.rfc1437.ironstorage.core.KeyTransferState
|
||||||
|
import de.rfc1437.ironstorage.core.ProtectedPresentation
|
||||||
|
import de.rfc1437.ironstorage.core.ProviderStatus
|
||||||
|
import de.rfc1437.ironstorage.core.SettingsPage
|
||||||
|
import de.rfc1437.ironstorage.core.SettingsRepository
|
||||||
|
import de.rfc1437.ironstorage.core.SettingsState
|
||||||
|
import de.rfc1437.ironstorage.core.TransferFrame
|
||||||
|
import de.rfc1437.ironstorage.features.ApplicationTheme
|
||||||
|
import de.rfc1437.ironstorage.features.SettingsScreen
|
||||||
|
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||||
|
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SettingsScreenTest {
|
||||||
|
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Test fun searchableSettingsKeepEditsAndUseExplicitSecretAndProviderActionsAtLargeFont() {
|
||||||
|
val original = ViewModelProvider(compose.activity)[ShellViewModel::class.java]
|
||||||
|
val setup = ViewModelProvider(compose.activity)[SetupFormViewModel::class.java]
|
||||||
|
val repository = Fixture()
|
||||||
|
val authentication = object : AuthenticationRepository by original.authentication {
|
||||||
|
override val state = MutableStateFlow(AuthenticationState())
|
||||||
|
override val presentation = ProtectedPresentation()
|
||||||
|
override fun reload() = Unit
|
||||||
|
override fun lock() {
|
||||||
|
state.value = AuthenticationState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.activity.runOnUiThread {
|
||||||
|
compose.activity.setProtectedTestContent(authentication.presentation) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
CompositionLocalProvider(LocalDensity provides Density(density.density, 2f)) {
|
||||||
|
ApplicationTheme(authentication) {
|
||||||
|
SettingsScreen(repository, authentication, original.onboarding, setup) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun search(text: String) {
|
||||||
|
compose.onNodeWithTag(
|
||||||
|
"settings-list"
|
||||||
|
).performScrollToNode(androidx.compose.ui.test.hasTestTag("settings-search"))
|
||||||
|
compose.onNodeWithTag("settings-search").performTextReplacement(text)
|
||||||
|
}
|
||||||
|
fun button(text: String) {
|
||||||
|
compose.onNodeWithTag("settings-list").performScrollToNode(hasText(text))
|
||||||
|
compose.onNodeWithText(text).assertIsDisplayed().performClick()
|
||||||
|
}
|
||||||
|
search("Git")
|
||||||
|
button("Edit Git author")
|
||||||
|
compose.onNodeWithTag("settings-git-name").performTextReplacement("New public author")
|
||||||
|
compose.onNodeWithTag("settings-git-email").performTextReplacement("public@example.test")
|
||||||
|
compose.onNodeWithText("Save").performClick()
|
||||||
|
assertEquals(listOf("New public author", "public@example.test"), repository.identity)
|
||||||
|
compose.runOnIdle {
|
||||||
|
repository.state.value =
|
||||||
|
repository.state.value.copy(error = "Typed validation failure")
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag(
|
||||||
|
"settings-dialog-error"
|
||||||
|
).performScrollTo().assertTextContains("Typed validation failure").assertIsDisplayed()
|
||||||
|
compose.onNodeWithTag("settings-git-name").assertTextContains("New public author")
|
||||||
|
compose.onNodeWithText("Cancel").performClick()
|
||||||
|
search("timeout")
|
||||||
|
button("Edit inactivity timeout")
|
||||||
|
compose.onNodeWithTag("settings-timeout").performTextReplacement("invalid candidate")
|
||||||
|
compose.onNodeWithText("Save").performClick()
|
||||||
|
assertEquals("invalid candidate", repository.timeoutInput)
|
||||||
|
compose.onNodeWithText("Cancel").performClick()
|
||||||
|
search("token")
|
||||||
|
button("Remove application token")
|
||||||
|
assertEquals(0, repository.removals)
|
||||||
|
compose.onNodeWithText("Save").performClick()
|
||||||
|
assertEquals(1, repository.removals)
|
||||||
|
compose.onNodeWithText("Cancel").performClick()
|
||||||
|
search("Autofill")
|
||||||
|
compose.onNodeWithTag(
|
||||||
|
"settings-list"
|
||||||
|
).performScrollToNode(
|
||||||
|
hasText("The IronStorage provider service is not included in this build")
|
||||||
|
)
|
||||||
|
compose.onAllNodes(
|
||||||
|
hasText("The IronStorage provider service is not included in this build")
|
||||||
|
)[0].assertIsDisplayed()
|
||||||
|
assertFalse(repository.state.value.page!!.autofill.enabled)
|
||||||
|
search("privacy")
|
||||||
|
button("MIT license")
|
||||||
|
compose.onNodeWithText("Done").assertIsDisplayed().performClick()
|
||||||
|
assertTrue(repository.reloads > 0)
|
||||||
|
search("GPG")
|
||||||
|
button("Import or export GPG QR codes")
|
||||||
|
compose.runOnIdle {
|
||||||
|
repository.state.value = repository.state.value.copy(
|
||||||
|
transfer = repository.state.value.transfer.copy(
|
||||||
|
frames = listOf(TransferFrame(1u, 1u, 21u, ByteArray(21 * 21) { 1 }))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
compose.onNodeWithContentDescription("QR frame 1 of 1")
|
||||||
|
.performScrollTo().assertWidthIsAtLeast(280.dp).assertHeightIsAtLeast(280.dp)
|
||||||
|
.assertIsDisplayed()
|
||||||
|
compose.onNodeWithText("Choose QR images").performScrollTo().performClick()
|
||||||
|
assertTrue(repository.choseImages)
|
||||||
|
val markers = listOf("private-export-passphrase-marker", "private-qr-payload-marker")
|
||||||
|
compose.onNodeWithText(
|
||||||
|
"GPG passphrase"
|
||||||
|
).performScrollTo().performTextReplacement(markers[0])
|
||||||
|
compose.onNodeWithText(
|
||||||
|
"QR payload or armored GPG key"
|
||||||
|
).performScrollTo().performTextReplacement(markers[1])
|
||||||
|
compose.runOnIdle {
|
||||||
|
val saved = Bundle()
|
||||||
|
InstrumentationRegistry.getInstrumentation().callActivityOnSaveInstanceState(
|
||||||
|
compose.activity,
|
||||||
|
saved
|
||||||
|
)
|
||||||
|
val parcel = Parcel.obtain()
|
||||||
|
try {
|
||||||
|
parcel.writeBundle(saved)
|
||||||
|
val bytes = parcel.marshall()
|
||||||
|
for (marker in markers) {
|
||||||
|
assertFalse(bytes.toString(Charsets.UTF_8).contains(marker))
|
||||||
|
assertFalse(bytes.toString(Charsets.UTF_16LE).contains(marker))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
parcel.recycle()
|
||||||
|
}
|
||||||
|
authentication.presentation.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private class Fixture : SettingsRepository {
|
||||||
|
override val state = MutableStateFlow(
|
||||||
|
SettingsState(
|
||||||
|
page = SettingsPage(
|
||||||
|
"Public fixture store",
|
||||||
|
"https://example.test/store.git",
|
||||||
|
"example.test",
|
||||||
|
"server-fixture",
|
||||||
|
"public-account",
|
||||||
|
"Fixture GPG key",
|
||||||
|
"PUBLIC-FINGERPRINT",
|
||||||
|
"Original author",
|
||||||
|
"original@example.test",
|
||||||
|
300u,
|
||||||
|
"No TOTP discovery cache.", false,
|
||||||
|
ProviderStatus(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
ProviderStatus(true, false, false), "fixture-version"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var identity = emptyList<String>()
|
||||||
|
var timeoutInput = ""
|
||||||
|
var removals = 0
|
||||||
|
var reloads = 0
|
||||||
|
var choseImages = false
|
||||||
|
override fun reload() {
|
||||||
|
reloads += 1
|
||||||
|
}
|
||||||
|
override fun setDefaultKey(fingerprint: String) = Unit
|
||||||
|
override fun setGitIdentity(name: String, email: String) {
|
||||||
|
identity = listOf(name, email)
|
||||||
|
}
|
||||||
|
override fun setTimeout(seconds: String) {
|
||||||
|
timeoutInput = seconds
|
||||||
|
}
|
||||||
|
override fun removeToken() {
|
||||||
|
removals += 1
|
||||||
|
}
|
||||||
|
override fun rebuildCache(passphrase: String) = Unit
|
||||||
|
override fun openAutofill() = Unit
|
||||||
|
override fun openCredentials() = Unit
|
||||||
|
override fun beginTransfer() {
|
||||||
|
state.value =
|
||||||
|
state.value.copy(error = null, transfer = KeyTransferState(open = true))
|
||||||
|
}
|
||||||
|
override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) = Unit
|
||||||
|
override fun chooseQrImages() {
|
||||||
|
choseImages = true
|
||||||
|
}
|
||||||
|
override fun addQrImages(uris: List<String>) = Unit
|
||||||
|
override fun addQrPayload(payload: String) = Unit
|
||||||
|
override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) = Unit
|
||||||
|
override fun closeTransfer() {
|
||||||
|
state.value = state.value.copy(transfer = KeyTransferState())
|
||||||
|
}
|
||||||
|
override fun cancel() = Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,12 +25,14 @@ import dagger.hilt.android.AndroidEntryPoint
|
|||||||
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
|
import de.rfc1437.ironstorage.core.AuthenticationLifecycle
|
||||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||||
|
import de.rfc1437.ironstorage.core.SettingsRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
import de.rfc1437.ironstorage.features.ApplicationTheme
|
import de.rfc1437.ironstorage.features.ApplicationTheme
|
||||||
import de.rfc1437.ironstorage.features.ProtectedContent
|
import de.rfc1437.ironstorage.features.ProtectedContent
|
||||||
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
import de.rfc1437.ironstorage.features.SetupFormViewModel
|
||||||
import de.rfc1437.ironstorage.features.ShellViewModel
|
import de.rfc1437.ironstorage.features.ShellViewModel
|
||||||
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
||||||
|
import de.rfc1437.ironstorage.platform.AndroidSettingsPlatform
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -43,6 +45,10 @@ class MainActivity : FragmentActivity() {
|
|||||||
|
|
||||||
@Inject lateinit var onboarding: OnboardingRepository
|
@Inject lateinit var onboarding: OnboardingRepository
|
||||||
|
|
||||||
|
@Inject lateinit var settingsPlatform: AndroidSettingsPlatform
|
||||||
|
|
||||||
|
@Inject lateinit var settings: SettingsRepository
|
||||||
|
|
||||||
private val setupForm: SetupFormViewModel by viewModels()
|
private val setupForm: SetupFormViewModel by viewModels()
|
||||||
|
|
||||||
private val viewModel: ShellViewModel by viewModels()
|
private val viewModel: ShellViewModel by viewModels()
|
||||||
@@ -105,6 +111,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
cleanup?.close()
|
cleanup?.close()
|
||||||
unregisterReceiver(protectedState)
|
unregisterReceiver(protectedState)
|
||||||
keystore.detach(this)
|
keystore.detach(this)
|
||||||
|
settingsPlatform.detach(this)
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +143,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
keystore.attach(this)
|
keystore.attach(this)
|
||||||
|
settingsPlatform.attach(this, settings::addQrImages)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
ProtectedContent(authentication.presentation) {
|
ProtectedContent(authentication.presentation) {
|
||||||
|
|||||||
@@ -17,12 +17,16 @@ import de.rfc1437.ironstorage.core.NativeOnboardingRepository
|
|||||||
import de.rfc1437.ironstorage.core.NativePasswordRepository
|
import de.rfc1437.ironstorage.core.NativePasswordRepository
|
||||||
import de.rfc1437.ironstorage.core.NativePrivateSession
|
import de.rfc1437.ironstorage.core.NativePrivateSession
|
||||||
import de.rfc1437.ironstorage.core.NativeSearchRepository
|
import de.rfc1437.ironstorage.core.NativeSearchRepository
|
||||||
|
import de.rfc1437.ironstorage.core.NativeSettingsRepository
|
||||||
import de.rfc1437.ironstorage.core.OnboardingRepository
|
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||||
import de.rfc1437.ironstorage.core.SearchRepository
|
import de.rfc1437.ironstorage.core.SearchRepository
|
||||||
|
import de.rfc1437.ironstorage.core.SettingsPlatform
|
||||||
|
import de.rfc1437.ironstorage.core.SettingsRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
import de.rfc1437.ironstorage.platform.AndroidKeystoreAdapter
|
||||||
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
|
import de.rfc1437.ironstorage.platform.AndroidSecretClipboard
|
||||||
|
import de.rfc1437.ironstorage.platform.AndroidSettingsPlatform
|
||||||
import de.rfc1437.ironstorage.platform.privateDirectories
|
import de.rfc1437.ironstorage.platform.privateDirectories
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -32,6 +36,29 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object ShellModule {
|
object ShellModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun androidSettingsPlatform(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
keystore: AndroidKeystoreAdapter
|
||||||
|
): AndroidSettingsPlatform = AndroidSettingsPlatform(context, keystore)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun settingsPlatform(platform: AndroidSettingsPlatform): SettingsPlatform = platform
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun settingsRepository(
|
||||||
|
session: NativePrivateSession,
|
||||||
|
authentication: NativeAuthenticationRepository,
|
||||||
|
platform: SettingsPlatform
|
||||||
|
): SettingsRepository = NativeSettingsRepository(
|
||||||
|
session,
|
||||||
|
authentication,
|
||||||
|
platform,
|
||||||
|
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun editorRepository(
|
fun editorRepository(
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||||
|
|
||||||
|
xjMEZZIAgBYJKwYBBAHaRw8BAQdANd08TxPj0ZuIl1cMvEf0FMCxubDwlzXDLX6w
|
||||||
|
9M+959XNJUJvYiBGaXh0dXJlIDxib2JAaXJvbnN0b3JhZ2UuaW52YWxpZD7CiQQT
|
||||||
|
FggAMQUCanjntBYhBLNwJ7VvxAa9P2piKyrANJK5ktBvAhsDAh4BAwsJBwIVCAEW
|
||||||
|
AScCGQEACgkQKsA0krmS0G9BCwD/daZy2BdmNQX8TldPBtoLTrugeaFmtrcVDQZ3
|
||||||
|
AzWEMcMA/16q87Zkaw2ozanLSBcRiq4iZnBTAmU/7bUSlA2myZoHzjgEZZIAgBIK
|
||||||
|
KwYBBAGXVQEFAQEHQByJpci1aMxEcUVlw9mByZUPyaQ7kI1emdJOuN1hcLEKAwEI
|
||||||
|
B8J4BBgWCAAgBQJqeOe1AhsMFiEEs3AntW/EBr0/amIrKsA0krmS0G8ACgkQKsA0
|
||||||
|
krmS0G+2lwEAvD9R7o52H37GsfeMS15UJBeIHBaYHjpsIyfoOWq0vsEA/j0oL4IG
|
||||||
|
SIakL3z1C1KHU+4iz3rzz2vNnhgGQd7+DS8H
|
||||||
|
=vS0q
|
||||||
|
-----END PGP PUBLIC KEY BLOCK-----
|
||||||
+148
-3
@@ -1,6 +1,7 @@
|
|||||||
package de.rfc1437.ironstorage.core
|
package de.rfc1437.ironstorage.core
|
||||||
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.MobileClock
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
|
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
|
||||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -29,9 +30,10 @@ class NativeEntryViewerTest {
|
|||||||
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
|
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
|
||||||
File(it, name).apply { mkdirs() }.canonicalPath
|
File(it, name).apply { mkdirs() }.canonicalPath
|
||||||
}
|
}
|
||||||
|
val keystore = MemoryKeystore()
|
||||||
val session = NativePrivateSession(context, {
|
val session = NativePrivateSession(context, {
|
||||||
PrivateDirectories(roots[0], roots[1], roots[2])
|
PrivateDirectories(roots[0], roots[1], roots[2])
|
||||||
}, MemoryKeystore())
|
}, keystore)
|
||||||
withContext(Dispatchers.IO) { session.initialize() }
|
withContext(Dispatchers.IO) { session.initialize() }
|
||||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
val clipboard = MemoryClipboard()
|
val clipboard = MemoryClipboard()
|
||||||
@@ -250,6 +252,13 @@ class NativeEntryViewerTest {
|
|||||||
scope,
|
scope,
|
||||||
password
|
password
|
||||||
)
|
)
|
||||||
|
settingsUseTheSameNativeOwnerAndProtectedTransfer(
|
||||||
|
session,
|
||||||
|
authentication,
|
||||||
|
scope,
|
||||||
|
password,
|
||||||
|
keystore
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
withContext(Dispatchers.Main.immediate) { repository.lock() }
|
withContext(Dispatchers.Main.immediate) { repository.lock() }
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
@@ -364,8 +373,8 @@ class NativeEntryViewerTest {
|
|||||||
authentication.authenticated { it.setAuthenticationTimeout(2u) }
|
authentication.authenticated { it.setAuthenticationTimeout(2u) }
|
||||||
withTimeout(10000) { editor.state.first { it.locked } }
|
withTimeout(10000) { editor.state.first { it.locked } }
|
||||||
assertTrue(editor.state.value.fields.isEmpty())
|
assertTrue(editor.state.value.fields.isEmpty())
|
||||||
assertNull(request { editor.resume(passphrase) }.error)
|
|
||||||
authentication.authenticated { it.setAuthenticationTimeout(300u) }
|
authentication.authenticated { it.setAuthenticationTimeout(300u) }
|
||||||
|
assertNull(request { editor.resume(passphrase) }.error)
|
||||||
request { editor.requestClose() }
|
request { editor.requestClose() }
|
||||||
if (editor.state.value.discardRequested) request { editor.confirmDiscard() }
|
if (editor.state.value.discardRequested) request { editor.confirmDiscard() }
|
||||||
assertFalse(editor.state.value.open)
|
assertFalse(editor.state.value.open)
|
||||||
@@ -373,6 +382,137 @@ class NativeEntryViewerTest {
|
|||||||
withContext(Dispatchers.Main.immediate) { authentication.lock() }
|
withContext(Dispatchers.Main.immediate) { authentication.lock() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private suspend fun settingsUseTheSameNativeOwnerAndProtectedTransfer(
|
||||||
|
session: NativePrivateSession,
|
||||||
|
authentication: NativeAuthenticationRepository,
|
||||||
|
scope: CoroutineScope,
|
||||||
|
passphrase: String,
|
||||||
|
keystore: MemoryKeystore
|
||||||
|
) {
|
||||||
|
var image = ByteArray(0)
|
||||||
|
val platform = object : SettingsPlatform {
|
||||||
|
override fun autofill() = ProviderStatus(true, false, false)
|
||||||
|
override fun credentials() =
|
||||||
|
ProviderStatus(android.os.Build.VERSION.SDK_INT >= 34, false, false)
|
||||||
|
override fun biometricsAvailable() = false
|
||||||
|
override fun version() = "native-settings-fixture"
|
||||||
|
override fun openAutofill() = true
|
||||||
|
override fun openCredentials() = true
|
||||||
|
override fun chooseQrImages() = true
|
||||||
|
override suspend fun readQrImage(uri: String) = image.copyOf()
|
||||||
|
}
|
||||||
|
val settings = NativeSettingsRepository(session, authentication, platform, scope)
|
||||||
|
suspend fun request(action: () -> Unit): SettingsState {
|
||||||
|
withContext(Dispatchers.Main.immediate) { action() }
|
||||||
|
return withTimeout(30000) { settings.state.first { !it.busy } }
|
||||||
|
}
|
||||||
|
val initial = request { settings.reload() }
|
||||||
|
assertNull(initial.error)
|
||||||
|
assertEquals("native-settings-fixture", initial.page!!.version)
|
||||||
|
assertFalse(initial.page.biometricAvailable)
|
||||||
|
assertNull(
|
||||||
|
request {
|
||||||
|
settings.setGitIdentity("Android settings author", "android-settings@example.test")
|
||||||
|
}.error
|
||||||
|
)
|
||||||
|
assertEquals("Android settings author", settings.state.value.page!!.gitName)
|
||||||
|
assertNotNull(request { settings.setGitIdentity("candidate", "invalid\nemail") }.error)
|
||||||
|
assertEquals("Android settings author", settings.state.value.page!!.gitName)
|
||||||
|
assertNull(request { settings.setTimeout("45") }.error)
|
||||||
|
assertEquals(45uL, settings.state.value.page!!.timeout)
|
||||||
|
assertNotNull(request { settings.setTimeout("0") }.error)
|
||||||
|
assertEquals(45uL, settings.state.value.page!!.timeout)
|
||||||
|
assertNotNull(request { settings.removeToken() }.error)
|
||||||
|
assertNull(settings.state.value.page!!.account)
|
||||||
|
assertNull(request { settings.rebuildCache(passphrase) }.error)
|
||||||
|
assertTrue(settings.state.value.page!!.cacheDetail.contains("current TOTP entries"))
|
||||||
|
val beforeEnrollment = keystore.keyCount
|
||||||
|
authentication.authenticated { it.enrollBiometricUnlock(passphrase) }
|
||||||
|
assertTrue(authentication.state.value.biometricEnabled)
|
||||||
|
assertEquals(beforeEnrollment + 1, keystore.keyCount)
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
session.storage().androidHomeOperation(
|
||||||
|
session.backend(),
|
||||||
|
object : MobileClock {
|
||||||
|
override fun monotonicMilliseconds(): ULong =
|
||||||
|
android.os.SystemClock.elapsedRealtime().toULong()
|
||||||
|
}
|
||||||
|
).use { home ->
|
||||||
|
assertEquals("Android settings author", home.identity().name)
|
||||||
|
home.cached()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(authentication.authenticated { it.state().unlocked })
|
||||||
|
keystore.invalidated = true
|
||||||
|
authentication.authenticated { it.setBiometricUnlock(false) }
|
||||||
|
assertFalse(authentication.state.value.biometricEnabled)
|
||||||
|
assertFalse(authentication.state.value.unlocked)
|
||||||
|
assertEquals(beforeEnrollment, keystore.keyCount)
|
||||||
|
keystore.invalidated = false
|
||||||
|
assertNull(request { settings.beginTransfer() }.error)
|
||||||
|
val fingerprint = settings.state.value.transfer.keys.first { it.privateKey }.fingerprint
|
||||||
|
assertNotNull(request { settings.exportKey(fingerprint, true, "incorrect") }.error)
|
||||||
|
assertTrue(settings.state.value.transfer.frames.isEmpty())
|
||||||
|
assertNull(request { settings.exportKey(fingerprint, false, "") }.error)
|
||||||
|
val frame = settings.state.value.transfer.frames.single()
|
||||||
|
val size = (frame.width.toInt() + 8) * 4
|
||||||
|
val pixels = IntArray(size * size) { android.graphics.Color.WHITE }
|
||||||
|
for (row in 0 until frame.width.toInt()) {
|
||||||
|
for (column in 0 until frame.width.toInt()) {
|
||||||
|
if (frame.modules[row * frame.width.toInt() + column].toInt() == 1) {
|
||||||
|
for (dy in 0 until 4) {
|
||||||
|
for (dx in 0 until 4) {
|
||||||
|
pixels[((row + 4) * 4 + dy) * size + (column + 4) * 4 + dx] =
|
||||||
|
android.graphics.Color.BLACK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val bitmap = android.graphics.Bitmap.createBitmap(
|
||||||
|
pixels,
|
||||||
|
size,
|
||||||
|
size,
|
||||||
|
android.graphics.Bitmap.Config.ARGB_8888
|
||||||
|
)
|
||||||
|
val output = java.io.ByteArrayOutputStream()
|
||||||
|
assertTrue(bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, output))
|
||||||
|
bitmap.recycle()
|
||||||
|
image = output.toByteArray()
|
||||||
|
request { settings.closeTransfer() }
|
||||||
|
assertTrue(frame.modules.all { it.toInt() == 0 })
|
||||||
|
assertNull(request { settings.addQrImages(listOf("content://public-fixture/qr")) }.error)
|
||||||
|
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
|
||||||
|
assertNotNull(request { settings.importKey("", false, false) }.error)
|
||||||
|
assertEquals(fingerprint, settings.state.value.transfer.ready!!.fingerprint)
|
||||||
|
request { settings.closeTransfer() }
|
||||||
|
assertNull(request { settings.beginTransfer() }.error)
|
||||||
|
val armor = InstrumentationRegistry.getInstrumentation().context.assets.open(
|
||||||
|
"bob-public.asc"
|
||||||
|
).bufferedReader().use {
|
||||||
|
it.readText()
|
||||||
|
}
|
||||||
|
assertNull(request { settings.addQrPayload(armor) }.error)
|
||||||
|
assertFalse(settings.state.value.transfer.ready!!.privateKey)
|
||||||
|
assertNull(request { settings.importKey("", false, false) }.error)
|
||||||
|
assertFalse(settings.state.value.transfer.open)
|
||||||
|
assertFalse(authentication.state.value.unlocked)
|
||||||
|
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
|
||||||
|
assertNull(request { settings.beginTransfer() }.error)
|
||||||
|
val bob = settings.state.value.transfer.keys.single { it.title.contains("Bob") }
|
||||||
|
assertNotNull(request { settings.setDefaultKey(bob.fingerprint) }.error)
|
||||||
|
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
|
||||||
|
assertNull(request { settings.beginTransfer() }.error)
|
||||||
|
assertNull(request { settings.exportKey(fingerprint, false, "") }.error)
|
||||||
|
withContext(Dispatchers.Main.immediate) { authentication.presentation.clear() }
|
||||||
|
assertFalse(settings.state.value.transfer.open)
|
||||||
|
assertTrue(settings.state.value.transfer.frames.isEmpty())
|
||||||
|
assertNull(request { settings.setDefaultKey(fingerprint) }.error)
|
||||||
|
assertFalse(authentication.state.value.unlocked)
|
||||||
|
assertEquals(fingerprint, settings.state.value.page!!.defaultKeyFingerprint)
|
||||||
|
request { settings.setTimeout("300") }
|
||||||
|
}
|
||||||
|
|
||||||
private class MemoryClipboard : SecretClipboard {
|
private class MemoryClipboard : SecretClipboard {
|
||||||
override fun beginCopy(): SecretClipboard = this
|
override fun beginCopy(): SecretClipboard = this
|
||||||
|
|
||||||
@@ -387,8 +527,10 @@ class NativeEntryViewerTest {
|
|||||||
}
|
}
|
||||||
private class MemoryKeystore : AndroidKeystore {
|
private class MemoryKeystore : AndroidKeystore {
|
||||||
private val keys = mutableSetOf<String>()
|
private val keys = mutableSetOf<String>()
|
||||||
|
val keyCount get() = keys.size
|
||||||
|
var invalidated = false
|
||||||
override fun deviceState() =
|
override fun deviceState() =
|
||||||
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, false, false)
|
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, true, false)
|
||||||
override fun createKey(specification: KeystoreKeySpecification) {
|
override fun createKey(specification: KeystoreKeySpecification) {
|
||||||
keys.add(specification.alias)
|
keys.add(specification.alias)
|
||||||
}
|
}
|
||||||
@@ -412,6 +554,9 @@ class NativeEntryViewerTest {
|
|||||||
aad: ByteArray,
|
aad: ByteArray,
|
||||||
value: KeystoreCiphertext
|
value: KeystoreCiphertext
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
|
if (invalidated && authorization == KeystoreAuthorization.StrongBiometric) {
|
||||||
|
throw KeystoreException(KeystoreFailure.Invalidated)
|
||||||
|
}
|
||||||
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
|
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
|
||||||
val record =
|
val record =
|
||||||
ciphertexts[value.bytes.toList()]
|
ciphertexts[value.bytes.toList()]
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
package de.rfc1437.ironstorage.core
|
||||||
|
|
||||||
|
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransfer
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferFfiException
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferImport
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferKind
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.MobileKeyTransferProgress
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.MobileTotpOperation
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.mobileKeyTransfer
|
||||||
|
import de.rfc1437.ironstorage.bridge.generated.mobileTotpOperation
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class NativeSettingsRepository(
|
||||||
|
private val session: NativePrivateSession,
|
||||||
|
private val authentication: NativeAuthenticationRepository,
|
||||||
|
private val platform: SettingsPlatform,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) : SettingsRepository {
|
||||||
|
private val mutable = MutableStateFlow(SettingsState())
|
||||||
|
override val state: StateFlow<SettingsState> = mutable
|
||||||
|
private val operation = Mutex()
|
||||||
|
|
||||||
|
@Volatile private var epoch = 0L
|
||||||
|
|
||||||
|
@Volatile private var discovery: MobileTotpOperation? = null
|
||||||
|
private var transfer: MobileKeyTransfer? = null
|
||||||
|
private var importer: MobileKeyTransferImport? = null
|
||||||
|
init {
|
||||||
|
val registration = authentication.presentation.register {
|
||||||
|
if (mutable.value.transfer.open) closeTransfer()
|
||||||
|
}
|
||||||
|
scope.coroutineContext[Job]?.invokeOnCompletion { registration.close() }
|
||||||
|
}
|
||||||
|
private suspend fun page(): SettingsPage {
|
||||||
|
val values = authentication.authenticated {
|
||||||
|
Triple(it.preferences(), it.gitIdentity(), it.totpCacheStatus())
|
||||||
|
}
|
||||||
|
val (preferences, identity, cache) = values
|
||||||
|
return SettingsPage(
|
||||||
|
preferences.repositoryTitle, preferences.repositoryUrl,
|
||||||
|
preferences.serverTitle, preferences.serverIdentity, preferences.applicationAccount,
|
||||||
|
preferences.defaultKeyTitle, preferences.defaultKeyFingerprint,
|
||||||
|
identity.name, identity.email, preferences.authenticationTimeoutSeconds,
|
||||||
|
cache.detail, platform.biometricsAvailable(), platform.autofill(),
|
||||||
|
platform.credentials(), platform.version()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun reload() = run { mutable.value = mutable.value.copy(page = page()) }
|
||||||
|
override fun setDefaultKey(fingerprint: String) = run {
|
||||||
|
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
|
||||||
|
mutable.value = mutable.value.copy(transfer = KeyTransferState())
|
||||||
|
releaseTransfer()
|
||||||
|
authentication.authenticated { it.setDefaultKey(fingerprint) }
|
||||||
|
authentication.lock()
|
||||||
|
mutable.value = mutable.value.copy(page = page(), notice = "Default GPG key updated.")
|
||||||
|
}
|
||||||
|
override fun setGitIdentity(name: String, email: String) = run {
|
||||||
|
authentication.authenticated { it.setGitIdentity(name, email) }
|
||||||
|
mutable.value = mutable.value.copy(page = page(), notice = "Git author updated.")
|
||||||
|
}
|
||||||
|
override fun setTimeout(seconds: String) = run {
|
||||||
|
authentication.authenticated { it.setAuthenticationTimeoutText(seconds) }
|
||||||
|
mutable.value = mutable.value.copy(page = page(), notice = "Inactivity timeout updated.")
|
||||||
|
}
|
||||||
|
override fun removeToken() = run {
|
||||||
|
authentication.authenticated { it.removeApplicationToken() }
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(
|
||||||
|
page = page(),
|
||||||
|
notice = "Application token removed from protected storage."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun rebuildCache(passphrase: String) = run {
|
||||||
|
val active = withContext(Dispatchers.IO) { mobileTotpOperation() }
|
||||||
|
discovery = active
|
||||||
|
try {
|
||||||
|
val result = authentication.authenticated {
|
||||||
|
it.unlockTotp(passphrase.ifEmpty { null })
|
||||||
|
it.rebuildTotpCache(active)
|
||||||
|
}
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(
|
||||||
|
page = page(),
|
||||||
|
notice =
|
||||||
|
result.cacheNotice ?: "TOTP cache rebuilt."
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
discovery = null
|
||||||
|
active.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun openAutofill() {
|
||||||
|
if (!platform.openAutofill()) {
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(
|
||||||
|
error = "Autofill provider settings are unavailable on this device."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun openCredentials() {
|
||||||
|
if (!platform.openCredentials()) {
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(
|
||||||
|
error = "Credential provider settings are unavailable on this device."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun beginTransfer() = run { ticket ->
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
session.storage()
|
||||||
|
transfer = mobileKeyTransfer()
|
||||||
|
importer = requireNotNull(transfer).importer()
|
||||||
|
}
|
||||||
|
val keys = withContext(Dispatchers.IO) { requireNotNull(transfer).keys() }
|
||||||
|
if (ticket ==
|
||||||
|
epoch
|
||||||
|
) {
|
||||||
|
mutable.value = mutable.value.copy(
|
||||||
|
transfer = KeyTransferState(
|
||||||
|
open = true,
|
||||||
|
keys = keys.map {
|
||||||
|
TransferKey(
|
||||||
|
it.fingerprint,
|
||||||
|
it.title,
|
||||||
|
it.kind == MobileKeyTransferKind.PRIVATE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String) =
|
||||||
|
run { ticket ->
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
requireNotNull(transfer).export(
|
||||||
|
fingerprint,
|
||||||
|
if (privateKey) MobileKeyTransferKind.PRIVATE else MobileKeyTransferKind.PUBLIC,
|
||||||
|
passphrase.ifEmpty { null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (ticket ==
|
||||||
|
epoch
|
||||||
|
) {
|
||||||
|
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
|
||||||
|
mutable.value = mutable.value.copy(
|
||||||
|
transfer = mutable.value.transfer.copy(
|
||||||
|
frames = result.frames.map {
|
||||||
|
TransferFrame(it.sequence, it.total, it.width, it.modules)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
result.frames.forEach { it.modules.fill(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun progress(ticket: Long, progress: MobileKeyTransferProgress) {
|
||||||
|
if (ticket != epoch) return
|
||||||
|
mutable.value = mutable.value.copy(
|
||||||
|
transfer = mutable.value.transfer.copy(
|
||||||
|
received = progress.received,
|
||||||
|
total = progress.total,
|
||||||
|
ready = progress.key?.let {
|
||||||
|
TransferKey(
|
||||||
|
it.fingerprint,
|
||||||
|
it.title,
|
||||||
|
it.kind == MobileKeyTransferKind.PRIVATE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun chooseQrImages() {
|
||||||
|
if (!platform.chooseQrImages()) {
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(error = "The image picker is unavailable on this device.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun addQrImages(uris: List<String>) {
|
||||||
|
scope.launch {
|
||||||
|
mutable.first { !it.busy }
|
||||||
|
run { ticket ->
|
||||||
|
if (importer == null) {
|
||||||
|
val keys = withContext(Dispatchers.IO) {
|
||||||
|
session.storage()
|
||||||
|
transfer = mobileKeyTransfer()
|
||||||
|
importer = requireNotNull(transfer).importer()
|
||||||
|
requireNotNull(transfer).keys()
|
||||||
|
}
|
||||||
|
if (ticket ==
|
||||||
|
epoch
|
||||||
|
) {
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(
|
||||||
|
transfer = KeyTransferState(
|
||||||
|
open = true,
|
||||||
|
keys = keys.map {
|
||||||
|
TransferKey(
|
||||||
|
it.fingerprint,
|
||||||
|
it.title,
|
||||||
|
it.kind == MobileKeyTransferKind.PRIVATE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (uri in uris) {
|
||||||
|
if (ticket != epoch) break
|
||||||
|
val bytes = withContext(Dispatchers.IO) { platform.readQrImage(uri) }
|
||||||
|
try {
|
||||||
|
progress(
|
||||||
|
ticket,
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
requireNotNull(importer).addImage(bytes)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
bytes.fill(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun addQrPayload(payload: String) = run { ticket ->
|
||||||
|
progress(ticket, withContext(Dispatchers.IO) { requireNotNull(importer).addFrame(payload) })
|
||||||
|
}
|
||||||
|
override fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean) = run {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
requireNotNull(importer).import(passphrase.ifEmpty { null }, makeDefault, overwrite)
|
||||||
|
}
|
||||||
|
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
|
||||||
|
mutable.value = mutable.value.copy(transfer = KeyTransferState())
|
||||||
|
releaseTransfer()
|
||||||
|
authentication.lock()
|
||||||
|
withContext(Dispatchers.IO) { session.storage().keyMaterialChanged() }
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(page = page(), notice = result.title + "\n" + result.detail)
|
||||||
|
}
|
||||||
|
private fun releaseTransfer() {
|
||||||
|
importer?.close()
|
||||||
|
transfer?.close()
|
||||||
|
importer = null
|
||||||
|
transfer = null
|
||||||
|
}
|
||||||
|
override fun closeTransfer() {
|
||||||
|
epoch += 1
|
||||||
|
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
|
||||||
|
mutable.value = mutable.value.copy(transfer = KeyTransferState(), busy = false)
|
||||||
|
scope.launch { operation.withLock { releaseTransfer() } }
|
||||||
|
}
|
||||||
|
override fun cancel() {
|
||||||
|
epoch += 1
|
||||||
|
discovery?.cancel()
|
||||||
|
closeTransfer()
|
||||||
|
}
|
||||||
|
private fun run(action: suspend (Long) -> Unit) {
|
||||||
|
if (mutable.value.busy) return
|
||||||
|
val ticket = epoch
|
||||||
|
mutable.value = mutable.value.copy(busy = true, error = null, notice = null)
|
||||||
|
scope.launch {
|
||||||
|
operation.withLock {
|
||||||
|
if (ticket != epoch) return@withLock
|
||||||
|
try {
|
||||||
|
action(ticket)
|
||||||
|
if (ticket != epoch) {
|
||||||
|
mutable.value.transfer.frames.forEach { it.modules.fill(0) }
|
||||||
|
mutable.value = mutable.value.copy(transfer = KeyTransferState())
|
||||||
|
}
|
||||||
|
} catch (error: MobileAuthenticationFfiException.Failed) {
|
||||||
|
if (ticket ==
|
||||||
|
epoch
|
||||||
|
) {
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(error = error.title + "\n" + error.detail)
|
||||||
|
}
|
||||||
|
} catch (error: MobileKeyTransferFfiException.Failed) {
|
||||||
|
if (ticket == epoch) mutable.value = mutable.value.copy(error = error.message)
|
||||||
|
} catch (_: MobilePrivateStorageFfiException) {
|
||||||
|
if (ticket ==
|
||||||
|
epoch
|
||||||
|
) {
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(
|
||||||
|
error =
|
||||||
|
"Private storage recovery required. " +
|
||||||
|
"Unlock the device and reload; " +
|
||||||
|
"missing or invalidated Keystore data requires " +
|
||||||
|
"reimporting the GPG key."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: NativeBridgeException) {
|
||||||
|
if (ticket ==
|
||||||
|
epoch
|
||||||
|
) {
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(error = "Update the application and retry.")
|
||||||
|
}
|
||||||
|
} catch (_: java.io.IOException) {
|
||||||
|
if (ticket ==
|
||||||
|
epoch
|
||||||
|
) {
|
||||||
|
mutable.value =
|
||||||
|
mutable.value.copy(error = "The selected QR image could not be read.")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (ticket == epoch) mutable.value = mutable.value.copy(busy = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package de.rfc1437.ironstorage.core
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
data class ProviderStatus(val supported: Boolean, val installed: Boolean, val enabled: Boolean)
|
||||||
|
interface SettingsPlatform {
|
||||||
|
fun autofill(): ProviderStatus
|
||||||
|
fun credentials(): ProviderStatus
|
||||||
|
fun biometricsAvailable(): Boolean
|
||||||
|
fun version(): String
|
||||||
|
fun openAutofill(): Boolean
|
||||||
|
fun openCredentials(): Boolean
|
||||||
|
fun chooseQrImages(): Boolean
|
||||||
|
suspend fun readQrImage(uri: String): ByteArray
|
||||||
|
}
|
||||||
|
data class SettingsPage(
|
||||||
|
val repositoryTitle: String,
|
||||||
|
val repositoryUrl: String,
|
||||||
|
val serverTitle: String,
|
||||||
|
val serverIdentity: String,
|
||||||
|
val account: String?,
|
||||||
|
val defaultKeyTitle: String,
|
||||||
|
val defaultKeyFingerprint: String,
|
||||||
|
val gitName: String,
|
||||||
|
val gitEmail: String,
|
||||||
|
val timeout: ULong,
|
||||||
|
val cacheDetail: String,
|
||||||
|
val biometricAvailable: Boolean,
|
||||||
|
val autofill: ProviderStatus,
|
||||||
|
val credentials: ProviderStatus,
|
||||||
|
val version: String
|
||||||
|
)
|
||||||
|
data class TransferKey(val fingerprint: String, val title: String, val privateKey: Boolean)
|
||||||
|
data class TransferFrame(
|
||||||
|
val sequence: UInt,
|
||||||
|
val total: UInt,
|
||||||
|
val width: UInt,
|
||||||
|
val modules: ByteArray
|
||||||
|
) {
|
||||||
|
override fun toString() = "TransferFrame([REDACTED])"
|
||||||
|
}
|
||||||
|
data class KeyTransferState(
|
||||||
|
val open: Boolean = false,
|
||||||
|
val keys: List<TransferKey> = emptyList(),
|
||||||
|
val frames: List<TransferFrame> = emptyList(),
|
||||||
|
val received: UInt = 0u,
|
||||||
|
val total: UInt = 0u,
|
||||||
|
val ready: TransferKey? = null
|
||||||
|
) {
|
||||||
|
override fun toString() = "KeyTransferState([REDACTED])"
|
||||||
|
}
|
||||||
|
data class SettingsState(
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val page: SettingsPage? = null,
|
||||||
|
val error: String? = null,
|
||||||
|
val notice: String? = null,
|
||||||
|
val transfer: KeyTransferState = KeyTransferState()
|
||||||
|
) {
|
||||||
|
override fun toString() = "SettingsState(busy=$busy, [REDACTED])"
|
||||||
|
}
|
||||||
|
interface SettingsRepository {
|
||||||
|
val state: StateFlow<SettingsState>
|
||||||
|
fun reload()
|
||||||
|
fun setDefaultKey(fingerprint: String)
|
||||||
|
fun setGitIdentity(name: String, email: String)
|
||||||
|
fun setTimeout(seconds: String)
|
||||||
|
fun removeToken()
|
||||||
|
fun rebuildCache(passphrase: String)
|
||||||
|
fun openAutofill()
|
||||||
|
fun openCredentials()
|
||||||
|
fun beginTransfer()
|
||||||
|
fun exportKey(fingerprint: String, privateKey: Boolean, passphrase: String)
|
||||||
|
fun chooseQrImages()
|
||||||
|
fun addQrImages(uris: List<String>)
|
||||||
|
fun addQrPayload(payload: String)
|
||||||
|
fun importKey(passphrase: String, makeDefault: Boolean, overwrite: Boolean)
|
||||||
|
fun closeTransfer()
|
||||||
|
fun cancel()
|
||||||
|
}
|
||||||
+2
-2
@@ -28,10 +28,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AuthenticationControls(repository: AuthenticationRepository) {
|
fun AuthenticationControls(repository: AuthenticationRepository, reload: Boolean = true) {
|
||||||
val state by repository.state.collectAsStateWithLifecycle()
|
val state by repository.state.collectAsStateWithLifecycle()
|
||||||
var enrollment by remember { mutableStateOf(false) }
|
var enrollment by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(repository) { repository.reload() }
|
LaunchedEffect(repository, reload) { if (reload) repository.reload() }
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { enrollment = false }
|
LifecycleEventEffect(Lifecycle.Event.ON_STOP) { enrollment = false }
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
Text(stringResource(R.string.auth_title))
|
Text(stringResource(R.string.auth_title))
|
||||||
|
|||||||
+1
-1
@@ -340,7 +340,7 @@ private fun Field(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SecretField(
|
internal fun SecretField(
|
||||||
label: String,
|
label: String,
|
||||||
value: String,
|
value: String,
|
||||||
singleLine: Boolean = true,
|
singleLine: Boolean = true,
|
||||||
|
|||||||
@@ -0,0 +1,600 @@
|
|||||||
|
package de.rfc1437.ironstorage.features
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
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.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalResources
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.compose.ui.window.SecureFlagPolicy
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import de.rfc1437.ironstorage.core.AuthenticationRepository
|
||||||
|
import de.rfc1437.ironstorage.core.OnboardingRepository
|
||||||
|
import de.rfc1437.ironstorage.core.ProviderStatus
|
||||||
|
import de.rfc1437.ironstorage.core.SettingsRepository
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(viewModel: ShellViewModel, setup: SetupFormViewModel) {
|
||||||
|
SettingsScreen(
|
||||||
|
viewModel.settings,
|
||||||
|
viewModel.authentication,
|
||||||
|
viewModel.onboarding,
|
||||||
|
setup,
|
||||||
|
viewModel::openAppearance
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
repository: SettingsRepository,
|
||||||
|
authentication: AuthenticationRepository,
|
||||||
|
onboarding: OnboardingRepository,
|
||||||
|
setup: SetupFormViewModel,
|
||||||
|
openAppearance: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by repository.state.collectAsStateWithLifecycle()
|
||||||
|
val authenticationState by authentication.state.collectAsStateWithLifecycle()
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
var edit by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showSetup by remember { mutableStateOf(false) }
|
||||||
|
var licenses by remember { mutableStateOf(false) }
|
||||||
|
val uri = LocalUriHandler.current
|
||||||
|
val setupState by onboarding.setup.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(setupState.busy, setupState.connected, setupState.selectedKey) {
|
||||||
|
if (!setupState.busy) repository.reload()
|
||||||
|
}
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { repository.reload() }
|
||||||
|
DisposableEffect(repository) { onDispose { repository.cancel() } }
|
||||||
|
val headings = listOf(
|
||||||
|
"store" to stringResource(R.string.settings_store),
|
||||||
|
"git" to stringResource(R.string.settings_git),
|
||||||
|
"auth" to stringResource(R.string.settings_auth),
|
||||||
|
"cache" to stringResource(R.string.settings_cache),
|
||||||
|
"providers" to stringResource(R.string.settings_providers),
|
||||||
|
"recovery" to stringResource(R.string.settings_recovery),
|
||||||
|
"about" to stringResource(R.string.settings_about)
|
||||||
|
)
|
||||||
|
val matches = headings.filter { (_, title) -> title.contains(query, ignoreCase = true) }
|
||||||
|
ProtectedInputSurface(authentication.presentation)
|
||||||
|
LazyColumn(
|
||||||
|
Modifier.fillMaxSize().testTag("settings-list"),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.settings_title),
|
||||||
|
style = MaterialTheme.typography.headlineSmall
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
query,
|
||||||
|
{
|
||||||
|
query = it
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(R.string.settings_search)) },
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("settings-search"),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
if (state.busy) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
TextButton(onClick = repository::cancel) {
|
||||||
|
Text(stringResource(R.string.auth_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
state.notice?.let { Text(it) }
|
||||||
|
TextButton(onClick = repository::reload) {
|
||||||
|
Text(stringResource(R.string.settings_reload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ((id, title) in matches) {
|
||||||
|
item(key = id) {
|
||||||
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
modifier = Modifier.semantics {
|
||||||
|
heading()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val page = state.page
|
||||||
|
when (id) {
|
||||||
|
"store" -> {
|
||||||
|
page?.let {
|
||||||
|
Text(it.repositoryTitle)
|
||||||
|
Text(it.repositoryUrl)
|
||||||
|
Text(it.serverTitle)
|
||||||
|
Text(it.serverIdentity)
|
||||||
|
Text(it.defaultKeyTitle)
|
||||||
|
Text(it.defaultKeyFingerprint)
|
||||||
|
Text(it.account ?: stringResource(R.string.settings_no_token))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.settings_token_hidden))
|
||||||
|
TextButton(onClick = {
|
||||||
|
showSetup = !showSetup
|
||||||
|
}) { Text(stringResource(R.string.settings_remote_token)) }
|
||||||
|
TextButton(enabled = !state.busy && page?.account != null, onClick = {
|
||||||
|
edit =
|
||||||
|
"remove"
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.settings_remove_token))
|
||||||
|
}
|
||||||
|
TextButton(enabled = !state.busy, onClick = repository::beginTransfer) {
|
||||||
|
Text(stringResource(R.string.settings_key_transfer))
|
||||||
|
}
|
||||||
|
if (showSetup) {
|
||||||
|
OnboardingScreen(
|
||||||
|
onboarding,
|
||||||
|
setup,
|
||||||
|
scroll = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"git" -> {
|
||||||
|
page?.let {
|
||||||
|
Text(it.gitName)
|
||||||
|
Text(it.gitEmail)
|
||||||
|
}
|
||||||
|
TextButton(enabled = !state.busy && page != null, onClick = {
|
||||||
|
edit = "git"
|
||||||
|
}) { Text(stringResource(R.string.settings_edit_git)) }
|
||||||
|
}
|
||||||
|
"auth" -> {
|
||||||
|
page?.let {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (it.biometricAvailable) {
|
||||||
|
R.string.settings_biometry_available
|
||||||
|
} else {
|
||||||
|
R.string.settings_biometry_unavailable
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.settings_timeout_value,
|
||||||
|
it.timeout.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(enabled = !state.busy && page != null, onClick = {
|
||||||
|
edit =
|
||||||
|
"timeout"
|
||||||
|
}) { Text(stringResource(R.string.settings_edit_timeout)) }
|
||||||
|
AuthenticationControls(authentication, reload = false)
|
||||||
|
TextButton(onClick = authentication::lock) {
|
||||||
|
Text(stringResource(R.string.settings_lock_now))
|
||||||
|
}
|
||||||
|
TextButton(onClick = {
|
||||||
|
setup.clearSecrets()
|
||||||
|
openAppearance()
|
||||||
|
}, modifier = Modifier.testTag("shell-open-appearance")) {
|
||||||
|
Text(stringResource(R.string.shell_appearance))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"cache" -> {
|
||||||
|
page?.let { Text(it.cacheDetail) }
|
||||||
|
TextButton(enabled = !state.busy && page != null, onClick = {
|
||||||
|
if (authenticationState.unlocked) {
|
||||||
|
repository.rebuildCache("")
|
||||||
|
} else {
|
||||||
|
edit = "cache"
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.settings_rebuild)) }
|
||||||
|
}
|
||||||
|
"providers" -> {
|
||||||
|
page?.let {
|
||||||
|
ProviderSetting(
|
||||||
|
stringResource(R.string.settings_autofill),
|
||||||
|
it.autofill,
|
||||||
|
repository::openAutofill
|
||||||
|
)
|
||||||
|
ProviderSetting(
|
||||||
|
stringResource(R.string.settings_credentials),
|
||||||
|
it.credentials,
|
||||||
|
repository::openCredentials
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"recovery" -> {
|
||||||
|
Text(stringResource(R.string.settings_recovery_key))
|
||||||
|
Text(stringResource(R.string.settings_recovery_store))
|
||||||
|
Text(stringResource(R.string.settings_recovery_cache))
|
||||||
|
Text(stringResource(R.string.settings_offline))
|
||||||
|
}
|
||||||
|
"about" -> {
|
||||||
|
page?.let {
|
||||||
|
Text(stringResource(R.string.settings_version, it.version))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.settings_privacy))
|
||||||
|
TextButton(onClick = {
|
||||||
|
licenses = true
|
||||||
|
}) { Text(stringResource(R.string.settings_licenses)) }
|
||||||
|
TextButton(onClick = {
|
||||||
|
uri.openUri("https://git.rfc1437.de/hugo/IronStorage/issues")
|
||||||
|
}) { Text(stringResource(R.string.settings_support)) }
|
||||||
|
TextButton(onClick = {
|
||||||
|
uri.openUri(
|
||||||
|
"https://git.rfc1437.de/hugo/IronStorage/" +
|
||||||
|
"src/branch/main/android/dependency-policy.json"
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.settings_dependency_licenses))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
edit?.let { action ->
|
||||||
|
if (action ==
|
||||||
|
"cache"
|
||||||
|
) {
|
||||||
|
PassphraseDialog(
|
||||||
|
stringResource(R.string.settings_rebuild),
|
||||||
|
stringResource(R.string.settings_rebuild_detail),
|
||||||
|
authentication.presentation,
|
||||||
|
onCancel = { edit = null },
|
||||||
|
onSubmit = {
|
||||||
|
edit =
|
||||||
|
null
|
||||||
|
repository.rebuildCache(it)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SettingsEditDialog(repository, authentication, action) { edit = null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.transfer.open) KeyTransferDialog(repository, authentication)
|
||||||
|
if (licenses) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
val text =
|
||||||
|
remember(resources) {
|
||||||
|
resources.openRawResource(R.raw.license).bufferedReader().use {
|
||||||
|
it.readText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
licenses = false
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.settings_licenses)) },
|
||||||
|
text = { Text(text, modifier = Modifier.verticalScroll(rememberScrollState())) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
licenses = false
|
||||||
|
}) { Text(stringResource(R.string.settings_done)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProviderSetting(title: String, status: ProviderStatus, open: () -> Unit) {
|
||||||
|
Text(title)
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
when {
|
||||||
|
!status.supported -> R.string.settings_provider_unsupported
|
||||||
|
!status.installed -> R.string.settings_provider_not_installed
|
||||||
|
status.enabled -> R.string.settings_provider_enabled
|
||||||
|
else -> R.string.settings_provider_disabled
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
TextButton(onClick = open) { Text(stringResource(R.string.settings_provider_open)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsEditDialog(
|
||||||
|
repository: SettingsRepository,
|
||||||
|
authentication: AuthenticationRepository,
|
||||||
|
action: String,
|
||||||
|
close: () -> Unit
|
||||||
|
) {
|
||||||
|
val state by repository.state.collectAsStateWithLifecycle()
|
||||||
|
var name by rememberProtectedInput(authentication.presentation)
|
||||||
|
var email by rememberProtectedInput(authentication.presentation)
|
||||||
|
var submitted by remember { mutableStateOf(false) }
|
||||||
|
var timeout by rememberProtectedInput(authentication.presentation)
|
||||||
|
LaunchedEffect(action) {
|
||||||
|
name = state.page?.gitName.orEmpty()
|
||||||
|
email =
|
||||||
|
state.page?.gitEmail.orEmpty()
|
||||||
|
timeout = state.page?.timeout?.toString().orEmpty()
|
||||||
|
}
|
||||||
|
ProtectedWindow()
|
||||||
|
AlertDialog(
|
||||||
|
properties = DialogProperties(securePolicy = SecureFlagPolicy.SecureOn),
|
||||||
|
onDismissRequest = close,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
when (action) {
|
||||||
|
"git" -> R.string.settings_edit_git
|
||||||
|
"timeout" -> R.string.settings_edit_timeout
|
||||||
|
else -> R.string.settings_remove_token
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
Modifier.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
ProtectedInputSurface(authentication.presentation)
|
||||||
|
when (action) {
|
||||||
|
"git" -> {
|
||||||
|
OutlinedTextField(name, {
|
||||||
|
name = it
|
||||||
|
}, label = {
|
||||||
|
Text(stringResource(R.string.settings_git_name))
|
||||||
|
}, modifier = Modifier.testTag("settings-git-name"))
|
||||||
|
OutlinedTextField(email, {
|
||||||
|
email = it
|
||||||
|
}, label = {
|
||||||
|
Text(stringResource(R.string.settings_git_email))
|
||||||
|
}, modifier = Modifier.testTag("settings-git-email"))
|
||||||
|
}
|
||||||
|
"timeout" -> OutlinedTextField(timeout, {
|
||||||
|
timeout = it
|
||||||
|
}, label = {
|
||||||
|
Text(stringResource(R.string.settings_timeout_seconds))
|
||||||
|
}, modifier = Modifier.testTag("settings-timeout"))
|
||||||
|
else -> Text(stringResource(R.string.settings_remove_detail))
|
||||||
|
}
|
||||||
|
state.error?.let {
|
||||||
|
Text(
|
||||||
|
it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.testTag("settings-dialog-error")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(enabled = !state.busy, onClick = {
|
||||||
|
submitted = true
|
||||||
|
when (action) {
|
||||||
|
"git" -> repository.setGitIdentity(name, email)
|
||||||
|
"timeout" -> repository.setTimeout(timeout)
|
||||||
|
else -> repository.removeToken()
|
||||||
|
}
|
||||||
|
}) { Text(stringResource(R.string.settings_save)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = close) { Text(stringResource(R.string.auth_cancel)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
LaunchedEffect(state.notice, state.busy) {
|
||||||
|
if (submitted && state.notice != null &&
|
||||||
|
!state.busy
|
||||||
|
) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KeyTransferDialog(
|
||||||
|
repository: SettingsRepository,
|
||||||
|
authentication: AuthenticationRepository
|
||||||
|
) {
|
||||||
|
val state by repository.state.collectAsStateWithLifecycle()
|
||||||
|
var fingerprint by remember {
|
||||||
|
mutableStateOf(state.transfer.keys.firstOrNull()?.fingerprint.orEmpty())
|
||||||
|
}
|
||||||
|
var privateKey by remember { mutableStateOf(false) }
|
||||||
|
var makeDefault by remember { mutableStateOf(false) }
|
||||||
|
var overwrite by remember { mutableStateOf(false) }
|
||||||
|
var frame by remember { mutableStateOf(0) }
|
||||||
|
var passphrase by rememberProtectedInput(authentication.presentation)
|
||||||
|
var payload by rememberProtectedInput(authentication.presentation)
|
||||||
|
ProtectedWindow()
|
||||||
|
AlertDialog(
|
||||||
|
properties = DialogProperties(
|
||||||
|
securePolicy = SecureFlagPolicy.SecureOn,
|
||||||
|
usePlatformDefaultWidth = false
|
||||||
|
),
|
||||||
|
onDismissRequest = repository::closeTransfer,
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.settings_key_transfer))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
ProtectedInputSurface(authentication.presentation)
|
||||||
|
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
if (state.busy) CircularProgressIndicator()
|
||||||
|
state.transfer.keys.forEach { key ->
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().selectable(
|
||||||
|
selected = fingerprint == key.fingerprint,
|
||||||
|
enabled = !state.busy,
|
||||||
|
role = Role.RadioButton,
|
||||||
|
onClick = { fingerprint = key.fingerprint }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
RadioButton(selected = fingerprint == key.fingerprint, onClick = null)
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(key.title)
|
||||||
|
Text(key.fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
enabled =
|
||||||
|
!state.busy &&
|
||||||
|
state.transfer.keys.any {
|
||||||
|
it.fingerprint == fingerprint && it.privateKey
|
||||||
|
},
|
||||||
|
onClick = { repository.setDefaultKey(fingerprint) }
|
||||||
|
) { Text(stringResource(R.string.settings_default_key_select)) }
|
||||||
|
Text(stringResource(R.string.settings_private_warning))
|
||||||
|
SettingsToggle(stringResource(R.string.settings_private_export), privateKey) {
|
||||||
|
privateKey =
|
||||||
|
it
|
||||||
|
}
|
||||||
|
SecretField(stringResource(R.string.setup_passphrase), passphrase) {
|
||||||
|
passphrase = it
|
||||||
|
}
|
||||||
|
TextButton(enabled = !state.busy && fingerprint.isNotEmpty(), onClick = {
|
||||||
|
repository.exportKey(fingerprint, privateKey, passphrase)
|
||||||
|
passphrase = ""
|
||||||
|
frame =
|
||||||
|
0
|
||||||
|
}) { Text(stringResource(R.string.settings_export_confirm)) }
|
||||||
|
state.transfer.frames.getOrNull(frame)?.let { qr ->
|
||||||
|
val description =
|
||||||
|
stringResource(
|
||||||
|
R.string.settings_qr_frame,
|
||||||
|
qr.sequence.toString(),
|
||||||
|
qr.total.toString()
|
||||||
|
)
|
||||||
|
Text(description)
|
||||||
|
Canvas(
|
||||||
|
Modifier.fillMaxWidth().height(280.dp).semantics {
|
||||||
|
contentDescription =
|
||||||
|
description
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
drawRect(Color.White)
|
||||||
|
val width = qr.width.toInt()
|
||||||
|
val scale = minOf(size.width, size.height) / (width + 8)
|
||||||
|
val x = (size.width - scale * width) / 2
|
||||||
|
val y = (size.height - scale * width) / 2
|
||||||
|
for (row in 0 until width) {
|
||||||
|
for (column in 0 until width) {
|
||||||
|
if (qr.modules[
|
||||||
|
row *
|
||||||
|
width +
|
||||||
|
column
|
||||||
|
].toInt() ==
|
||||||
|
1
|
||||||
|
) {
|
||||||
|
drawRect(
|
||||||
|
Color.Black,
|
||||||
|
Offset(x + column * scale, y + row * scale),
|
||||||
|
Size(scale, scale)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlowRow {
|
||||||
|
TextButton(enabled = frame > 0, onClick = {
|
||||||
|
frame -= 1
|
||||||
|
}) { Text(stringResource(R.string.settings_previous)) }
|
||||||
|
TextButton(enabled = frame < state.transfer.frames.lastIndex, onClick = {
|
||||||
|
frame +=
|
||||||
|
1
|
||||||
|
}) { Text(stringResource(R.string.settings_next)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextButton(enabled = !state.busy, onClick = repository::chooseQrImages) {
|
||||||
|
Text(stringResource(R.string.settings_qr_images))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.settings_qr_images_detail))
|
||||||
|
SecretField(
|
||||||
|
stringResource(R.string.settings_qr_payload),
|
||||||
|
payload,
|
||||||
|
singleLine = false
|
||||||
|
) {
|
||||||
|
payload =
|
||||||
|
it
|
||||||
|
}
|
||||||
|
TextButton(enabled = !state.busy && payload.isNotEmpty(), onClick = {
|
||||||
|
repository.addQrPayload(payload)
|
||||||
|
payload =
|
||||||
|
""
|
||||||
|
}) { Text(stringResource(R.string.settings_qr_add)) }
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.settings_qr_progress,
|
||||||
|
state.transfer.received.toString(),
|
||||||
|
state.transfer.total.toString()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state.transfer.ready?.let {
|
||||||
|
Text(it.title)
|
||||||
|
Text(it.fingerprint)
|
||||||
|
SettingsToggle(stringResource(R.string.settings_make_default), makeDefault) {
|
||||||
|
makeDefault =
|
||||||
|
it
|
||||||
|
}
|
||||||
|
SettingsToggle(stringResource(R.string.settings_replace_key), overwrite) {
|
||||||
|
overwrite =
|
||||||
|
it
|
||||||
|
}
|
||||||
|
TextButton(enabled = !state.busy, onClick = {
|
||||||
|
repository.importKey(passphrase, makeDefault, overwrite)
|
||||||
|
passphrase =
|
||||||
|
""
|
||||||
|
}) { Text(stringResource(R.string.settings_import_confirm)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = repository::closeTransfer) {
|
||||||
|
Text(stringResource(R.string.settings_done))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsToggle(label: String, checked: Boolean, change: (Boolean) -> Unit) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().toggleable(
|
||||||
|
value = checked,
|
||||||
|
role = Role.Checkbox,
|
||||||
|
onValueChange = change
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Checkbox(checked, onCheckedChange = null)
|
||||||
|
Text(label, modifier = Modifier.padding(vertical = 12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,7 @@ private fun ShellDestination(
|
|||||||
(snapshot.state == ShellState.Empty && setup.configured)
|
(snapshot.state == ShellState.Empty && setup.configured)
|
||||||
)
|
)
|
||||||
when {
|
when {
|
||||||
|
!snapshot.fixture && route == ShellRoute.Preferences -> SettingsScreen(viewModel, setupForm)
|
||||||
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
|
!snapshot.fixture && snapshot.nativeFailure == null && snapshot.storageRecovery == null &&
|
||||||
(
|
(
|
||||||
(snapshot.state == ShellState.Empty && !setup.configured) ||
|
(snapshot.state == ShellState.Empty && !setup.configured) ||
|
||||||
@@ -168,18 +169,6 @@ private fun ShellDestination(
|
|||||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
if (ready && route == ShellRoute.Preferences) {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
setupForm.clearSecrets()
|
|
||||||
viewModel.openAppearance()
|
|
||||||
},
|
|
||||||
modifier = Modifier.testTag("shell-open-appearance")
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.shell_appearance))
|
|
||||||
}
|
|
||||||
AuthenticationControls(viewModel.authentication)
|
|
||||||
}
|
|
||||||
OnboardingScreen(viewModel.onboarding, setupForm, scroll = false)
|
OnboardingScreen(viewModel.onboarding, setupForm, scroll = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import de.rfc1437.ironstorage.core.OnboardingRepository
|
|||||||
import de.rfc1437.ironstorage.core.PasswordRepository
|
import de.rfc1437.ironstorage.core.PasswordRepository
|
||||||
import de.rfc1437.ironstorage.core.PasswordRow
|
import de.rfc1437.ironstorage.core.PasswordRow
|
||||||
import de.rfc1437.ironstorage.core.SearchRepository
|
import de.rfc1437.ironstorage.core.SearchRepository
|
||||||
|
import de.rfc1437.ironstorage.core.SettingsRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellRepository
|
import de.rfc1437.ironstorage.core.ShellRepository
|
||||||
import de.rfc1437.ironstorage.core.ShellState
|
import de.rfc1437.ironstorage.core.ShellState
|
||||||
import de.rfc1437.ironstorage.core.ShellTab
|
import de.rfc1437.ironstorage.core.ShellTab
|
||||||
@@ -28,6 +29,7 @@ class ShellViewModel @Inject constructor(
|
|||||||
val search: SearchRepository,
|
val search: SearchRepository,
|
||||||
val authentication: AuthenticationRepository,
|
val authentication: AuthenticationRepository,
|
||||||
val editor: EditorRepository,
|
val editor: EditorRepository,
|
||||||
|
val settings: SettingsRepository,
|
||||||
private val saved: SavedStateHandle
|
private val saved: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val snapshot = repository.snapshot
|
val snapshot = repository.snapshot
|
||||||
@@ -110,7 +112,10 @@ class ShellViewModel @Inject constructor(
|
|||||||
ShellTab.Home -> home.leave()
|
ShellTab.Home -> home.leave()
|
||||||
ShellTab.Passwords -> passwords.leave()
|
ShellTab.Passwords -> passwords.leave()
|
||||||
ShellTab.Search -> search.cancel()
|
ShellTab.Search -> search.cancel()
|
||||||
ShellTab.Preferences -> if (authentication.state.value.busy) authentication.cancel()
|
ShellTab.Preferences -> {
|
||||||
|
settings.cancel()
|
||||||
|
if (authentication.state.value.busy) authentication.cancel()
|
||||||
|
}
|
||||||
ShellTab.Totp -> Unit
|
ShellTab.Totp -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 rfc1437
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -148,4 +148,64 @@
|
|||||||
<string name="editor_name">Name</string>
|
<string name="editor_name">Name</string>
|
||||||
<string name="editor_create">Create</string>
|
<string name="editor_create">Create</string>
|
||||||
<string name="editor_length_help">Leave blank for the default. Maximum: %1$d.</string>
|
<string name="editor_length_help">Leave blank for the default. Maximum: %1$d.</string>
|
||||||
|
<string name="settings_title">Preferences</string>
|
||||||
|
<string name="settings_default_key_select">Use selected private key as default</string>
|
||||||
|
<string name="settings_save">Save</string>
|
||||||
|
<string name="settings_search">Search settings</string>
|
||||||
|
<string name="settings_reload">Reload settings</string>
|
||||||
|
<string name="settings_store">Password store, server, token and GPG keys</string>
|
||||||
|
<string name="settings_git">Git commit author name and email</string>
|
||||||
|
<string name="settings_auth">Authentication, inactivity timeout, biometrics and appearance</string>
|
||||||
|
<string name="settings_cache">TOTP discovery cache</string>
|
||||||
|
<string name="settings_providers">Autofill and Credential Manager</string>
|
||||||
|
<string name="settings_recovery">Recovery and offline use</string>
|
||||||
|
<string name="settings_about">Version, licenses, privacy and support</string>
|
||||||
|
<string name="settings_no_token">No application token stored</string>
|
||||||
|
<string name="settings_token_hidden">Existing tokens and passphrases are never displayed. Replacement stores only the submitted value in protected device storage.</string>
|
||||||
|
<string name="settings_remote_token">HTTPS setup and token replacement</string>
|
||||||
|
<string name="settings_remove_token">Remove application token</string>
|
||||||
|
<string name="settings_key_transfer">Import or export GPG QR codes</string>
|
||||||
|
<string name="settings_edit_git">Edit Git author</string>
|
||||||
|
<string name="settings_biometry_available">Strong biometric authentication is available</string>
|
||||||
|
<string name="settings_biometry_unavailable">Strong biometric authentication is unavailable; use a GPG passphrase</string>
|
||||||
|
<string name="settings_timeout_value">Inactivity timeout: %1$s seconds</string>
|
||||||
|
<string name="settings_edit_timeout">Edit inactivity timeout</string>
|
||||||
|
<string name="settings_lock_now">Lock now</string>
|
||||||
|
<string name="settings_rebuild">Rebuild TOTP cache</string>
|
||||||
|
<string name="settings_autofill">Autofill password provider</string>
|
||||||
|
<string name="settings_credentials">Credential Manager password provider</string>
|
||||||
|
<string name="settings_recovery_key">If biometric enrollment changes or a protected passphrase is invalidated, unlock with the GPG passphrase and enable biometric unlock again. A missing device Keystore binding requires recovery from your original repository and GPG key; encrypted restored data is never silently accepted or deleted.</string>
|
||||||
|
<string name="settings_recovery_store">If the repository or GPG key is missing, keep existing data and restore the original encrypted repository and private key. Reload after recovery to see the storage-provided status.</string>
|
||||||
|
<string name="settings_recovery_cache">A damaged or outdated TOTP cache can be rebuilt after unlocking. The cache contains discovery metadata, never OTP seeds or codes.</string>
|
||||||
|
<string name="settings_offline">Offline use keeps local reading, editing and Git history available. HTTPS sync needs a network and an application token; failed network operations keep local data intact.</string>
|
||||||
|
<string name="settings_version">IronStorage %1$s</string>
|
||||||
|
<string name="settings_privacy">Passwords and private keys stay in protected app storage. No analytics or advertising are included. HTTPS synchronization connects only to the configured server. Secrets are hidden from screenshots, saved form state, logs and personalized keyboard learning.</string>
|
||||||
|
<string name="settings_licenses">MIT license</string>
|
||||||
|
<string name="settings_support">Support and issue tracker</string>
|
||||||
|
<string name="settings_dependency_licenses">Dependency license inventory</string>
|
||||||
|
<string name="settings_done">Done</string>
|
||||||
|
<string name="settings_provider_unsupported">Not supported on this Android version or device</string>
|
||||||
|
<string name="settings_provider_not_installed">The IronStorage provider service is not included in this build</string>
|
||||||
|
<string name="settings_provider_enabled">Enabled for IronStorage</string>
|
||||||
|
<string name="settings_provider_disabled">Available for IronStorage, currently disabled</string>
|
||||||
|
<string name="settings_provider_open">Open system provider settings</string>
|
||||||
|
<string name="settings_rebuild_detail">Enter the GPG passphrase to inspect encrypted entries and rebuild the discovery cache. No OTP seed is stored in the cache.</string>
|
||||||
|
<string name="settings_git_name">Git author name</string>
|
||||||
|
<string name="settings_git_email">Git author email</string>
|
||||||
|
<string name="settings_timeout_seconds">Timeout in seconds (1–86400)</string>
|
||||||
|
<string name="settings_remove_detail">Remove the protected HTTPS application token? Local data and commits remain available. Authenticated synchronization will require a replacement token.</string>
|
||||||
|
<string name="settings_private_warning">Private QR codes contain encrypted secret key material. Export only after checking the intended recipient. A private export requires its GPG passphrase.</string>
|
||||||
|
<string name="settings_private_export">Export private key</string>
|
||||||
|
<string name="settings_export_confirm">Confirm and export selected key</string>
|
||||||
|
<string name="settings_qr_frame">QR frame %1$s of %2$s</string>
|
||||||
|
<string name="settings_previous">Previous frame</string>
|
||||||
|
<string name="settings_next">Next frame</string>
|
||||||
|
<string name="settings_qr_images">Choose QR images</string>
|
||||||
|
<string name="settings_qr_images_detail">Select all frames of one transfer together. Opening another app clears the current transfer.</string>
|
||||||
|
<string name="settings_qr_payload">QR payload or armored GPG key</string>
|
||||||
|
<string name="settings_qr_add">Add transfer payload</string>
|
||||||
|
<string name="settings_qr_progress">Received %1$s of %2$s frames</string>
|
||||||
|
<string name="settings_make_default">Make imported private key the default</string>
|
||||||
|
<string name="settings_replace_key">Confirm replacement of an existing key</string>
|
||||||
|
<string name="settings_import_confirm">Confirm and import reconstructed key</string>
|
||||||
</resources>
|
</resources>
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
package de.rfc1437.ironstorage.platform
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.credentials.CredentialManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.autofill.AutofillManager
|
||||||
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import de.rfc1437.ironstorage.core.ProviderStatus
|
||||||
|
import de.rfc1437.ironstorage.core.SettingsPlatform
|
||||||
|
|
||||||
|
class AndroidSettingsPlatform(
|
||||||
|
private val context: Context,
|
||||||
|
private val keystore: AndroidKeystoreAdapter
|
||||||
|
) : SettingsPlatform {
|
||||||
|
private var picker: ActivityResultLauncher<String>? = null
|
||||||
|
private var host: java.lang.ref.WeakReference<FragmentActivity> = java.lang.ref.WeakReference(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
fun attach(activity: FragmentActivity, selected: (List<String>) -> Unit) {
|
||||||
|
host = java.lang.ref.WeakReference(activity)
|
||||||
|
picker =
|
||||||
|
activity.activityResultRegistry.register(
|
||||||
|
"ironstorage.settings.qr-images",
|
||||||
|
activity,
|
||||||
|
ActivityResultContracts.GetMultipleContents()
|
||||||
|
) { uris ->
|
||||||
|
if (uris.isNotEmpty()) selected(uris.map { it.toString() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun detach(activity: FragmentActivity) {
|
||||||
|
if (host.get() === activity) {
|
||||||
|
picker = null
|
||||||
|
host.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun chooseQrImages(): Boolean {
|
||||||
|
val picker = picker ?: return false
|
||||||
|
return try {
|
||||||
|
picker.launch("image/*")
|
||||||
|
true
|
||||||
|
} catch (
|
||||||
|
_: android.content.ActivityNotFoundException
|
||||||
|
) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun services(permission: String): List<ComponentName> =
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, PackageManager.GET_SERVICES)
|
||||||
|
.services.orEmpty().filter { it.permission == permission }
|
||||||
|
.map { ComponentName(context.packageName, it.name) }
|
||||||
|
override fun autofill(): ProviderStatus {
|
||||||
|
val manager = context.getSystemService(AutofillManager::class.java)
|
||||||
|
return ProviderStatus(
|
||||||
|
manager?.isAutofillSupported == true,
|
||||||
|
services("android.permission.BIND_AUTOFILL_SERVICE").isNotEmpty(),
|
||||||
|
manager?.hasEnabledAutofillServices() == true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun credentials(): ProviderStatus {
|
||||||
|
val services = services("android.permission.BIND_CREDENTIAL_PROVIDER_SERVICE")
|
||||||
|
val enabled = if (Build.VERSION.SDK_INT >= 34) {
|
||||||
|
val manager = context.getSystemService(CredentialManager::class.java)
|
||||||
|
services.any { manager?.isEnabledCredentialProviderService(it) == true }
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
return ProviderStatus(Build.VERSION.SDK_INT >= 34, services.isNotEmpty(), enabled)
|
||||||
|
}
|
||||||
|
override fun biometricsAvailable() = keystore.deviceState().strongBiometric
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
override fun version(): String = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
.versionName.orEmpty()
|
||||||
|
private fun open(intent: Intent): Boolean = try {
|
||||||
|
context.startActivity(intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
true
|
||||||
|
} catch (
|
||||||
|
_: android.content.ActivityNotFoundException
|
||||||
|
) {
|
||||||
|
false
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
override fun openAutofill(): Boolean {
|
||||||
|
if (!autofill().supported) return false
|
||||||
|
return open(
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE,
|
||||||
|
"package:${context.packageName}".toUri()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override fun openCredentials(): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < 34) return false
|
||||||
|
val intent = if (Build.VERSION.SDK_INT >= 35) {
|
||||||
|
Intent(Settings.ACTION_CREDENTIAL_PROVIDER, "package:${context.packageName}".toUri())
|
||||||
|
} else {
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE,
|
||||||
|
"package:${context.packageName}".toUri()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return open(intent)
|
||||||
|
}
|
||||||
|
override suspend fun readQrImage(uri: String): ByteArray {
|
||||||
|
val selected = uri.toUri()
|
||||||
|
if (selected.scheme != "content") throw java.io.IOException("Not a content URI")
|
||||||
|
return context.contentResolver.openInputStream(selected)?.use { input ->
|
||||||
|
val output = java.io.ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(16384)
|
||||||
|
while (true) {
|
||||||
|
val count = input.read(buffer)
|
||||||
|
if (count < 0) break
|
||||||
|
if (output.size() + count >
|
||||||
|
32 * 1024 * 1024
|
||||||
|
) {
|
||||||
|
throw java.io.IOException("Image exceeds the supported size")
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, count)
|
||||||
|
}
|
||||||
|
val bytes = output.toByteArray()
|
||||||
|
if (bytes.size > 32 * 1024 * 1024) {
|
||||||
|
bytes.fill(0)
|
||||||
|
throw java.io.IOException("Image exceeds the supported size")
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
} ?: throw java.io.IOException("Image unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1452,6 +1452,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func prepareWatchSnapshot(platformPairingIdentity: String) throws -> MobileWatchSnapshotTransfer
|
func prepareWatchSnapshot(platformPairingIdentity: String) throws -> MobileWatchSnapshotTransfer
|
||||||
|
|
||||||
|
func rebuildTotpCache(operation: MobileTotpOperation) throws -> MobileTotpPage
|
||||||
|
|
||||||
func refreshEntryPresentation(path: String, revision: String, unixSeconds: UInt64) throws -> MobileEntryPresentation
|
func refreshEntryPresentation(path: String, revision: String, unixSeconds: UInt64) throws -> MobileEntryPresentation
|
||||||
|
|
||||||
func removeApplicationToken() throws
|
func removeApplicationToken() throws
|
||||||
@@ -1478,8 +1480,12 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func setAuthenticationTimeout(seconds: UInt64) throws
|
func setAuthenticationTimeout(seconds: UInt64) throws
|
||||||
|
|
||||||
|
func setAuthenticationTimeoutText(seconds: String) throws
|
||||||
|
|
||||||
func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState
|
func setBiometricUnlock(enabled: Bool) throws -> MobileAuthenticationState
|
||||||
|
|
||||||
|
func setDefaultKey(fingerprint: String) throws
|
||||||
|
|
||||||
func setGitIdentity(name: String, email: String) throws -> MobileGitIdentity
|
func setGitIdentity(name: String, email: String) throws -> MobileGitIdentity
|
||||||
|
|
||||||
func setMobileAppearance(appearance: MobileAppearance) throws
|
func setMobileAppearance(appearance: MobileAppearance) throws
|
||||||
@@ -1490,6 +1496,8 @@ public protocol MobileAuthenticationProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func state() throws -> MobileAuthenticationState
|
func state() throws -> MobileAuthenticationState
|
||||||
|
|
||||||
|
func totpCacheStatus() throws -> MobileTotpCacheStatus
|
||||||
|
|
||||||
func totpDetail(path: String, unixSeconds: UInt64) throws -> MobileTotpDetail
|
func totpDetail(path: String, unixSeconds: UInt64) throws -> MobileTotpDetail
|
||||||
|
|
||||||
func totpPage(operation: MobileTotpOperation) throws -> MobileTotpPage
|
func totpPage(operation: MobileTotpOperation) throws -> MobileTotpPage
|
||||||
@@ -1868,6 +1876,16 @@ open func prepareWatchSnapshot(platformPairingIdentity: String)throws -> Mobile
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func rebuildTotpCache(operation: MobileTotpOperation)throws -> MobileTotpPage {
|
||||||
|
return try FfiConverterTypeMobileTotpPage_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_mobile_fn_method_mobileauthentication_rebuild_totp_cache(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterTypeMobileTotpOperation_lower(operation),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
open func refreshEntryPresentation(path: String, revision: String, unixSeconds: UInt64)throws -> MobileEntryPresentation {
|
open func refreshEntryPresentation(path: String, revision: String, unixSeconds: UInt64)throws -> MobileEntryPresentation {
|
||||||
return try FfiConverterTypeMobileEntryPresentation_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
return try FfiConverterTypeMobileEntryPresentation_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -2009,6 +2027,15 @@ open func setAuthenticationTimeout(seconds: UInt64)throws {try rustCallWithErr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func setAuthenticationTimeoutText(seconds: String)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_authentication_timeout_text(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterString.lower(seconds),uniffiCallStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState {
|
open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState {
|
||||||
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
return try FfiConverterTypeMobileAuthenticationState_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -2019,6 +2046,15 @@ open func setBiometricUnlock(enabled: Bool)throws -> MobileAuthenticationState
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func setDefaultKey(fingerprint: String)throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_default_key(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterString.lower(fingerprint),uniffiCallStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
open func setGitIdentity(name: String, email: String)throws -> MobileGitIdentity {
|
open func setGitIdentity(name: String, email: String)throws -> MobileGitIdentity {
|
||||||
return try FfiConverterTypeMobileGitIdentity_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
return try FfiConverterTypeMobileGitIdentity_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -2071,6 +2107,15 @@ open func state()throws -> MobileAuthenticationState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func totpCacheStatus()throws -> MobileTotpCacheStatus {
|
||||||
|
return try FfiConverterTypeMobileTotpCacheStatus_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_mobile_fn_method_mobileauthentication_totp_cache_status(
|
||||||
|
self.uniffiCloneHandle(),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
open func totpDetail(path: String, unixSeconds: UInt64)throws -> MobileTotpDetail {
|
open func totpDetail(path: String, unixSeconds: UInt64)throws -> MobileTotpDetail {
|
||||||
return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
return try FfiConverterTypeMobileTotpDetail_lift(try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -3083,6 +3128,8 @@ public protocol MobileKeyTransferImportProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func addFrame(payload: String) throws -> MobileKeyTransferProgress
|
func addFrame(payload: String) throws -> MobileKeyTransferProgress
|
||||||
|
|
||||||
|
func addImage(image: Data) throws -> MobileKeyTransferProgress
|
||||||
|
|
||||||
func `import`(passphrase: String?, makeDefault: Bool, overwrite: Bool) throws -> MobileKeyTransferOutcome
|
func `import`(passphrase: String?, makeDefault: Bool, overwrite: Bool) throws -> MobileKeyTransferOutcome
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -3149,6 +3196,16 @@ open func addFrame(payload: String)throws -> MobileKeyTransferProgress {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func addImage(image: Data)throws -> MobileKeyTransferProgress {
|
||||||
|
return try FfiConverterTypeMobileKeyTransferProgress_lift(try rustCallWithError(FfiConverterTypeMobileKeyTransferFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_mobile_fn_method_mobilekeytransferimport_add_image(
|
||||||
|
self.uniffiCloneHandle(),
|
||||||
|
FfiConverterData.lower(image),uniffiCallStatus
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
open func `import`(passphrase: String?, makeDefault: Bool, overwrite: Bool)throws -> MobileKeyTransferOutcome {
|
open func `import`(passphrase: String?, makeDefault: Bool, overwrite: Bool)throws -> MobileKeyTransferOutcome {
|
||||||
return try FfiConverterTypeMobileKeyTransferOutcome_lift(try rustCallWithError(FfiConverterTypeMobileKeyTransferFfiError_lift) {
|
return try FfiConverterTypeMobileKeyTransferOutcome_lift(try rustCallWithError(FfiConverterTypeMobileKeyTransferFfiError_lift) {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -3523,6 +3580,8 @@ public protocol MobilePrivateRuntimeProtocol: AnyObject, Sendable {
|
|||||||
|
|
||||||
func clearDisposableCache() throws
|
func clearDisposableCache() throws
|
||||||
|
|
||||||
|
func keyMaterialChanged() throws
|
||||||
|
|
||||||
func layout() -> MobilePrivateStorageLayout
|
func layout() -> MobilePrivateStorageLayout
|
||||||
|
|
||||||
func shell() -> MobileShell
|
func shell() -> MobileShell
|
||||||
@@ -3677,6 +3736,14 @@ open func clearDisposableCache()throws {try rustCallWithError(FfiConverterType
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
open func keyMaterialChanged()throws {try rustCallWithError(FfiConverterTypeMobileAuthenticationFfiError_lift) {
|
||||||
|
uniffiCallStatus in
|
||||||
|
uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_key_material_changed(
|
||||||
|
self.uniffiCloneHandle(),uniffiCallStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
open func layout() -> MobilePrivateStorageLayout {
|
open func layout() -> MobilePrivateStorageLayout {
|
||||||
return try! FfiConverterTypeMobilePrivateStorageLayout_lift(try! rustCall() {
|
return try! FfiConverterTypeMobilePrivateStorageLayout_lift(try! rustCall() {
|
||||||
uniffiCallStatus in
|
uniffiCallStatus in
|
||||||
@@ -7514,6 +7581,60 @@ public func FfiConverterTypeMobileShell_lower(_ value: MobileShell) -> RustBuffe
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public struct MobileTotpCacheStatus: Equatable, Hashable {
|
||||||
|
public var entries: UInt32
|
||||||
|
public var detail: String
|
||||||
|
|
||||||
|
// Default memberwise initializers are never public by default, so we
|
||||||
|
// declare one manually.
|
||||||
|
public init(entries: UInt32, detail: String) {
|
||||||
|
self.entries = entries
|
||||||
|
self.detail = detail
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
#if compiler(>=6)
|
||||||
|
extension MobileTotpCacheStatus: Sendable {}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public struct FfiConverterTypeMobileTotpCacheStatus: FfiConverterRustBuffer {
|
||||||
|
public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MobileTotpCacheStatus {
|
||||||
|
return
|
||||||
|
try MobileTotpCacheStatus(
|
||||||
|
entries: FfiConverterUInt32.read(from: &buf),
|
||||||
|
detail: FfiConverterString.read(from: &buf)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func write(_ value: MobileTotpCacheStatus, into buf: inout [UInt8]) {
|
||||||
|
FfiConverterUInt32.write(value.entries, into: &buf)
|
||||||
|
FfiConverterString.write(value.detail, into: &buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileTotpCacheStatus_lift(_ buf: RustBuffer) throws -> MobileTotpCacheStatus {
|
||||||
|
return try FfiConverterTypeMobileTotpCacheStatus.lift(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
#if swift(>=5.8)
|
||||||
|
@_documentation(visibility: private)
|
||||||
|
#endif
|
||||||
|
public func FfiConverterTypeMobileTotpCacheStatus_lower(_ value: MobileTotpCacheStatus) -> RustBuffer {
|
||||||
|
return FfiConverterTypeMobileTotpCacheStatus.lower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public struct MobileTotpDetail: Equatable, Hashable {
|
public struct MobileTotpDetail: Equatable, Hashable {
|
||||||
public var path: String
|
public var path: String
|
||||||
public var issuer: String?
|
public var issuer: String?
|
||||||
@@ -12800,6 +12921,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_prepare_watch_snapshot() != 63315) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_prepare_watch_snapshot() != 63315) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_rebuild_totp_cache() != 9132) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_refresh_entry_presentation() != 17168) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_refresh_entry_presentation() != 17168) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -12839,9 +12963,15 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_authentication_timeout() != 28648) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_authentication_timeout() != 28648) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_authentication_timeout_text() != 45986) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_biometric_unlock() != 30931) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_biometric_unlock() != 30931) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_default_key() != 57050) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_git_identity() != 30660) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_git_identity() != 30660) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -12857,6 +12987,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_state() != 55462) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_state() != 55462) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_totp_cache_status() != 41435) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_totp_detail() != 1912) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileauthentication_totp_detail() != 1912) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -12929,6 +13062,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_mobile_checksum_method_mobilekeytransferimport_add_frame() != 62030) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobilekeytransferimport_add_frame() != 62030) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_mobile_checksum_method_mobilekeytransferimport_add_image() != 8151) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_ironstorage_mobile_checksum_method_mobilekeytransferimport_import() != 64195) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobilekeytransferimport_import() != 64195) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -13076,6 +13212,9 @@ private let initializationResult: InitializationResult = {
|
|||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_clear_disposable_cache() != 35831) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_clear_disposable_cache() != 35831) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
|
if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_key_material_changed() != 45867) {
|
||||||
|
return InitializationResult.apiChecksumMismatch
|
||||||
|
}
|
||||||
if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_layout() != 33040) {
|
if (uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_layout() != 33040) {
|
||||||
return InitializationResult.apiChecksumMismatch
|
return InitializationResult.apiChecksumMismatch
|
||||||
}
|
}
|
||||||
@@ -13359,6 +13498,10 @@ extension MobileShell: CustomStringConvertible, CustomDebugStringConvertible {
|
|||||||
public var description: String { "MobileShell([REDACTED])" }
|
public var description: String { "MobileShell([REDACTED])" }
|
||||||
public var debugDescription: String { description }
|
public var debugDescription: String { description }
|
||||||
}
|
}
|
||||||
|
extension MobileTotpCacheStatus: CustomStringConvertible, CustomDebugStringConvertible {
|
||||||
|
public var description: String { "MobileTotpCacheStatus([REDACTED])" }
|
||||||
|
public var debugDescription: String { description }
|
||||||
|
}
|
||||||
extension MobileTotpDetail: CustomStringConvertible, CustomDebugStringConvertible {
|
extension MobileTotpDetail: CustomStringConvertible, CustomDebugStringConvertible {
|
||||||
public var description: String { "MobileTotpDetail([REDACTED])" }
|
public var description: String { "MobileTotpDetail([REDACTED])" }
|
||||||
public var debugDescription: String { description }
|
public var debugDescription: String { description }
|
||||||
|
|||||||
@@ -574,6 +574,11 @@ RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_prepare_pres
|
|||||||
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_prepare_watch_snapshot(uint64_t ptr, RustBuffer platform_pairing_identity, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_prepare_watch_snapshot(uint64_t ptr, RustBuffer platform_pairing_identity, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REBUILD_TOTP_CACHE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REBUILD_TOTP_CACHE
|
||||||
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_rebuild_totp_cache(uint64_t ptr, uint64_t operation, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REFRESH_ENTRY_PRESENTATION
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REFRESH_ENTRY_PRESENTATION
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REFRESH_ENTRY_PRESENTATION
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_REFRESH_ENTRY_PRESENTATION
|
||||||
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_refresh_entry_presentation(uint64_t ptr, RustBuffer path, RustBuffer revision, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_refresh_entry_presentation(uint64_t ptr, RustBuffer path, RustBuffer revision, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||||
@@ -639,11 +644,21 @@ RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_search_cache
|
|||||||
void uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_authentication_timeout(uint64_t ptr, uint64_t seconds, RustCallStatus *_Nonnull out_status
|
void uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_authentication_timeout(uint64_t ptr, uint64_t seconds, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT_TEXT
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT_TEXT
|
||||||
|
void uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_authentication_timeout_text(uint64_t ptr, RustBuffer seconds, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_biometric_unlock(uint64_t ptr, int8_t enabled, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_DEFAULT_KEY
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_DEFAULT_KEY
|
||||||
|
void uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_default_key(uint64_t ptr, RustBuffer fingerprint, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY
|
||||||
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_git_identity(uint64_t ptr, RustBuffer name, RustBuffer email, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_git_identity(uint64_t ptr, RustBuffer name, RustBuffer email, RustCallStatus *_Nonnull out_status
|
||||||
@@ -669,6 +684,11 @@ RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_set_watch_sn
|
|||||||
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_state(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_state(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_CACHE_STATUS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_CACHE_STATUS
|
||||||
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_totp_cache_status(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL
|
||||||
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_totp_detail(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileauthentication_totp_detail(uint64_t ptr, RustBuffer path, uint64_t unix_seconds, RustCallStatus *_Nonnull out_status
|
||||||
@@ -819,6 +839,11 @@ void uniffi_ironstorage_mobile_fn_free_mobilekeytransferimport(uint64_t handle,
|
|||||||
RustBuffer uniffi_ironstorage_mobile_fn_method_mobilekeytransferimport_add_frame(uint64_t ptr, RustBuffer payload, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobilekeytransferimport_add_frame(uint64_t ptr, RustBuffer payload, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_ADD_IMAGE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_ADD_IMAGE
|
||||||
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobilekeytransferimport_add_image(uint64_t ptr, RustBuffer image, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
||||||
RustBuffer uniffi_ironstorage_mobile_fn_method_mobilekeytransferimport_import(uint64_t ptr, RustBuffer passphrase, int8_t make_default, int8_t overwrite, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobilekeytransferimport_import(uint64_t ptr, RustBuffer passphrase, int8_t make_default, int8_t overwrite, RustCallStatus *_Nonnull out_status
|
||||||
@@ -1184,6 +1209,11 @@ uint64_t uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_authentication
|
|||||||
void uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_clear_disposable_cache(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
void uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_clear_disposable_cache(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_KEY_MATERIAL_CHANGED
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_KEY_MATERIAL_CHANGED
|
||||||
|
void uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_key_material_changed(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
|
);
|
||||||
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_LAYOUT
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_LAYOUT
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_LAYOUT
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_FN_METHOD_MOBILEPRIVATERUNTIME_LAYOUT
|
||||||
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_layout(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
RustBuffer uniffi_ironstorage_mobile_fn_method_mobileprivateruntime_layout(uint64_t ptr, RustCallStatus *_Nonnull out_status
|
||||||
@@ -1908,6 +1938,12 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_prepare_
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_PREPARE_WATCH_SNAPSHOT
|
||||||
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_prepare_watch_snapshot(void
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_prepare_watch_snapshot(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REBUILD_TOTP_CACHE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REBUILD_TOTP_CACHE
|
||||||
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_rebuild_totp_cache(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REFRESH_ENTRY_PRESENTATION
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_REFRESH_ENTRY_PRESENTATION
|
||||||
@@ -1986,12 +2022,24 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_search_c
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT
|
||||||
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_authentication_timeout(void
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_authentication_timeout(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT_TEXT
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_AUTHENTICATION_TIMEOUT_TEXT
|
||||||
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_authentication_timeout_text(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_BIOMETRIC_UNLOCK
|
||||||
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_biometric_unlock(void
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_biometric_unlock(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_DEFAULT_KEY
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_DEFAULT_KEY
|
||||||
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_default_key(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_SET_GIT_IDENTITY
|
||||||
@@ -2022,6 +2070,12 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_set_watc
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_STATE
|
||||||
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_state(void
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_state(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_CACHE_STATUS
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_CACHE_STATUS
|
||||||
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileauthentication_totp_cache_status(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEAUTHENTICATION_TOTP_DETAIL
|
||||||
@@ -2166,6 +2220,12 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobilekeytransfer_keys(void
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_ADD_FRAME
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_ADD_FRAME
|
||||||
uint16_t uniffi_ironstorage_mobile_checksum_method_mobilekeytransferimport_add_frame(void
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobilekeytransferimport_add_frame(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_ADD_IMAGE
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_ADD_IMAGE
|
||||||
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobilekeytransferimport_add_image(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEKEYTRANSFERIMPORT_IMPORT
|
||||||
@@ -2460,6 +2520,12 @@ uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_authenti
|
|||||||
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_CLEAR_DISPOSABLE_CACHE
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_CLEAR_DISPOSABLE_CACHE
|
||||||
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_clear_disposable_cache(void
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_clear_disposable_cache(void
|
||||||
|
|
||||||
|
);
|
||||||
|
#endif
|
||||||
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_KEY_MATERIAL_CHANGED
|
||||||
|
#define UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_KEY_MATERIAL_CHANGED
|
||||||
|
uint16_t uniffi_ironstorage_mobile_checksum_method_mobileprivateruntime_key_material_changed(void
|
||||||
|
|
||||||
);
|
);
|
||||||
#endif
|
#endif
|
||||||
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_LAYOUT
|
#ifndef UNIFFI_FFIDEF_UNIFFI_IRONSTORAGE_MOBILE_CHECKSUM_METHOD_MOBILEPRIVATERUNTIME_LAYOUT
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ impl os::AndroidKeystore for KeystoreAdapter {
|
|||||||
self.0.cancel_pending();
|
self.0.cancel_pending();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub(crate) struct RustSecretBackend(pub os::AndroidSecretBackend);
|
pub(crate) struct RustSecretBackend(pub Arc<os::AndroidSecretBackend>);
|
||||||
impl TryFrom<MobileSecretLocator> for SecretLocator {
|
impl TryFrom<MobileSecretLocator> for SecretLocator {
|
||||||
type Error = MobileSecretError;
|
type Error = MobileSecretError;
|
||||||
fn try_from(value: MobileSecretLocator) -> Result<Self, Self::Error> {
|
fn try_from(value: MobileSecretLocator) -> Result<Self, Self::Error> {
|
||||||
|
|||||||
@@ -857,6 +857,12 @@ pub struct MobileGitIdentity {
|
|||||||
pub email: String,
|
pub email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, uniffi::Record)]
|
||||||
|
pub struct MobileTotpCacheStatus {
|
||||||
|
pub entries: u32,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, uniffi::Enum)]
|
||||||
pub enum MobileMutationAction {
|
pub enum MobileMutationAction {
|
||||||
Move,
|
Move,
|
||||||
@@ -1629,6 +1635,21 @@ pub struct MobileKeyTransferImport {
|
|||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
impl MobileKeyTransferImport {
|
impl MobileKeyTransferImport {
|
||||||
|
pub fn add_image(
|
||||||
|
&self,
|
||||||
|
image: Vec<u8>,
|
||||||
|
) -> Result<MobileKeyTransferProgress, MobileKeyTransferFfiError> {
|
||||||
|
self.importer
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| MobileKeyTransferFfiError::Failed {
|
||||||
|
kind: MobileKeyTransferErrorKind::Failed,
|
||||||
|
message: "the key-transfer session is unavailable".to_owned(),
|
||||||
|
})?
|
||||||
|
.add_image(ironstorage::repository::SecretBytes::new(image))
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_frame(
|
pub fn add_frame(
|
||||||
&self,
|
&self,
|
||||||
payload: String,
|
payload: String,
|
||||||
@@ -1712,6 +1733,32 @@ pub struct MobileAuthentication {
|
|||||||
|
|
||||||
#[uniffi::export]
|
#[uniffi::export]
|
||||||
impl MobileAuthentication {
|
impl MobileAuthentication {
|
||||||
|
pub fn set_authentication_timeout_text(
|
||||||
|
&self,
|
||||||
|
seconds: String,
|
||||||
|
) -> Result<(), MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.set_authentication_timeout_text(&seconds)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn totp_cache_status(&self) -> Result<MobileTotpCacheStatus, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.totp_cache_status()
|
||||||
|
.map(|(entries, detail)| MobileTotpCacheStatus { entries, detail })
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rebuild_totp_cache(
|
||||||
|
&self,
|
||||||
|
operation: Arc<MobileTotpOperation>,
|
||||||
|
) -> Result<MobileTotpPage, MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.rebuild_totp_cache(&operation.operation)
|
||||||
|
.map(Into::into)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn state(&self) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
|
pub fn state(&self) -> Result<MobileAuthenticationState, MobileAuthenticationFfiError> {
|
||||||
self.authentication
|
self.authentication
|
||||||
.state()
|
.state()
|
||||||
@@ -1760,6 +1807,12 @@ impl MobileAuthentication {
|
|||||||
.map_err(Into::into)
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_default_key(&self, fingerprint: String) -> Result<(), MobileAuthenticationFfiError> {
|
||||||
|
self.authentication
|
||||||
|
.set_default_key(&fingerprint)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_git_identity(
|
pub fn set_git_identity(
|
||||||
&self,
|
&self,
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
@@ -170,6 +170,10 @@ impl MobilePrivateRuntime {
|
|||||||
pub fn clear_disposable_cache(&self) -> Result<(), MobilePrivateStorageFfiError> {
|
pub fn clear_disposable_cache(&self) -> Result<(), MobilePrivateStorageFfiError> {
|
||||||
self.storage.clear_disposable_cache().map_err(Into::into)
|
self.storage.clear_disposable_cache().map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
pub fn key_material_changed(&self) -> Result<(), crate::MobileAuthenticationFfiError> {
|
||||||
|
self.storage.key_material_changed().map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn authentication(
|
pub fn authentication(
|
||||||
&self,
|
&self,
|
||||||
backend: Arc<dyn MobileSecretBackend>,
|
backend: Arc<dyn MobileSecretBackend>,
|
||||||
|
|||||||
@@ -289,6 +289,23 @@ impl<B: SecretStoreBackend, C: AuthenticationClock> AuthenticationSession<B, C>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn delete_key_passphrases_transaction(
|
||||||
|
&self,
|
||||||
|
keys: impl Iterator<Item = KeyInfo>,
|
||||||
|
commit: &mut dyn FnMut() -> Result<(), SecretStoreError>,
|
||||||
|
) -> Result<(), AuthenticationError> {
|
||||||
|
let _operation = self.shared.operation()?;
|
||||||
|
self.shared.expire_if_needed()?;
|
||||||
|
self.shared.store.unlock()?;
|
||||||
|
let references = keys
|
||||||
|
.map(|key| SecretReference::openpgp_passphrase(key.fingerprint().as_str()))
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
self.shared
|
||||||
|
.store
|
||||||
|
.delete_openpgp_transaction(&references, commit)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
/// Remaining active time. Calling this from a timer never extends the lease.
|
/// Remaining active time. Calling this from a timer never extends the lease.
|
||||||
pub fn remaining_time(&self) -> Result<Option<Duration>, AuthenticationError> {
|
pub fn remaining_time(&self) -> Result<Option<Duration>, AuthenticationError> {
|
||||||
let _operation = self.shared.operation()?;
|
let _operation = self.shared.operation()?;
|
||||||
|
|||||||
@@ -653,12 +653,21 @@ impl MobileAuthentication {
|
|||||||
handle: &MobileAuthenticationHandle,
|
handle: &MobileAuthenticationHandle,
|
||||||
key: &KeyInfo,
|
key: &KeyInfo,
|
||||||
) -> Result<(), MobileAuthenticationError> {
|
) -> Result<(), MobileAuthenticationError> {
|
||||||
|
let current = Config::load(Some(self.config.source())).map_err(config_error)?;
|
||||||
handle
|
handle
|
||||||
.persist_passphrase(key)
|
.persist_passphrase(key)
|
||||||
.map_err(MobileAuthenticationError::authentication)?;
|
.map_err(MobileAuthenticationError::authentication)?;
|
||||||
|
if current.biometric_unlock_enabled() {
|
||||||
|
self.status()?.biometric_unlock_enabled = true;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if let Err(error) = self.config.update_biometric_unlock(true) {
|
if let Err(error) = self.config.update_biometric_unlock(true) {
|
||||||
let _ = self.delete_all_key_passphrases();
|
if matches!(error, ConfigError::DurabilityUncertain { .. }) {
|
||||||
|
self.status()?.biometric_unlock_enabled = true;
|
||||||
|
} else {
|
||||||
|
let _ = self.session.delete_key_passphrase(key);
|
||||||
let _ = self.manual_lock();
|
let _ = self.manual_lock();
|
||||||
|
}
|
||||||
return Err(config_error(error));
|
return Err(config_error(error));
|
||||||
}
|
}
|
||||||
self.status()?.biometric_unlock_enabled = true;
|
self.status()?.biometric_unlock_enabled = true;
|
||||||
@@ -685,10 +694,36 @@ impl MobileAuthentication {
|
|||||||
drop(status);
|
drop(status);
|
||||||
self.persist_biometric_unlock(&handle, &key)?;
|
self.persist_biometric_unlock(&handle, &key)?;
|
||||||
} else {
|
} else {
|
||||||
self.delete_all_key_passphrases()?;
|
// Validate the live document before removing any protected item.
|
||||||
self.config
|
Config::load(Some(self.config.source())).map_err(config_error)?;
|
||||||
.update_biometric_unlock(false)
|
let mut configuration_error = None;
|
||||||
.map_err(config_error)?;
|
let mut commit = || match self.config.update_biometric_unlock(false) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(error @ ConfigError::DurabilityUncertain { .. }) => {
|
||||||
|
configuration_error = Some(error);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
configuration_error = Some(error);
|
||||||
|
Err(SecretStoreError::Unavailable)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let removed = self.session.delete_key_passphrases_transaction(
|
||||||
|
self.keys.infos().filter(KeyInfo::has_secret),
|
||||||
|
&mut commit,
|
||||||
|
);
|
||||||
|
if let Err(error) = removed {
|
||||||
|
if Config::load(Some(self.config.source()))
|
||||||
|
.is_ok_and(|config| !config.biometric_unlock_enabled())
|
||||||
|
{
|
||||||
|
self.manual_lock()?;
|
||||||
|
self.status()?.biometric_unlock_enabled = false;
|
||||||
|
}
|
||||||
|
return Err(configuration_error.map_or_else(
|
||||||
|
|| MobileAuthenticationError::authentication(error),
|
||||||
|
config_error,
|
||||||
|
));
|
||||||
|
}
|
||||||
self.session
|
self.session
|
||||||
.manual_lock()
|
.manual_lock()
|
||||||
.map_err(MobileAuthenticationError::authentication)?;
|
.map_err(MobileAuthenticationError::authentication)?;
|
||||||
@@ -696,6 +731,9 @@ impl MobileAuthentication {
|
|||||||
status.biometric_unlock_enabled = false;
|
status.biometric_unlock_enabled = false;
|
||||||
status.active = None;
|
status.active = None;
|
||||||
status.editors.clear();
|
status.editors.clear();
|
||||||
|
if let Some(error) = configuration_error {
|
||||||
|
return Err(config_error(error));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.state()
|
self.state()
|
||||||
}
|
}
|
||||||
@@ -744,6 +782,24 @@ impl MobileAuthentication {
|
|||||||
Ok(identity)
|
Ok(identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_default_key(&self, fingerprint: &str) -> Result<(), MobileAuthenticationError> {
|
||||||
|
self.ensure_repository_idle()?;
|
||||||
|
let key = self
|
||||||
|
.keys
|
||||||
|
.infos()
|
||||||
|
.find(|key| key.fingerprint().as_str() == fingerprint && key.has_secret())
|
||||||
|
.ok_or_else(|| key_error(CryptoError::MissingSecretKey))?;
|
||||||
|
let current = Config::load(Some(self.config.source())).map_err(config_error)?;
|
||||||
|
let mut settings = current.settings();
|
||||||
|
settings.set_default_key(key.fingerprint().as_str().to_owned());
|
||||||
|
current
|
||||||
|
.with_settings(settings)
|
||||||
|
.and_then(|candidate| candidate.persist())
|
||||||
|
.map_err(config_error)?;
|
||||||
|
self.manual_lock()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn preferences(
|
pub fn preferences(
|
||||||
&self,
|
&self,
|
||||||
watch_supported: bool,
|
watch_supported: bool,
|
||||||
@@ -828,6 +884,17 @@ impl MobileAuthentication {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_authentication_timeout_text(
|
||||||
|
&self,
|
||||||
|
seconds: &str,
|
||||||
|
) -> Result<(), MobileAuthenticationError> {
|
||||||
|
let seconds = seconds
|
||||||
|
.trim()
|
||||||
|
.parse::<u64>()
|
||||||
|
.map_err(|_| config_detail("Choose an inactivity timeout from 1 to 86400 seconds."))?;
|
||||||
|
self.set_authentication_timeout(seconds)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_authentication_timeout(
|
pub fn set_authentication_timeout(
|
||||||
&self,
|
&self,
|
||||||
seconds: u64,
|
seconds: u64,
|
||||||
@@ -1160,6 +1227,27 @@ impl MobileAuthentication {
|
|||||||
pub fn totp_page(
|
pub fn totp_page(
|
||||||
&self,
|
&self,
|
||||||
operation: &MobileTotpOperation,
|
operation: &MobileTotpOperation,
|
||||||
|
) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
||||||
|
self.discover_totp(operation, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rebuild_totp_cache(
|
||||||
|
&self,
|
||||||
|
operation: &MobileTotpOperation,
|
||||||
|
) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
||||||
|
self.discover_totp(operation, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn totp_cache_status(&self) -> Result<(u32, String), MobileAuthenticationError> {
|
||||||
|
self.ensure_repository_idle()?;
|
||||||
|
Ok(MobileTotpService::new(&self.repository, &self.keys)
|
||||||
|
.cache_status(&self.config.source().with_file_name("totp-catalog.toml")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_totp(
|
||||||
|
&self,
|
||||||
|
operation: &MobileTotpOperation,
|
||||||
|
rebuild: bool,
|
||||||
) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
) -> Result<MobileTotpPage, MobileAuthenticationError> {
|
||||||
self.ensure_repository_idle()?;
|
self.ensure_repository_idle()?;
|
||||||
self.ensure_active()?;
|
self.ensure_active()?;
|
||||||
@@ -1180,8 +1268,12 @@ impl MobileAuthentication {
|
|||||||
};
|
};
|
||||||
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
|
let cache_path = self.config.source().with_file_name("totp-catalog.toml");
|
||||||
let mut provider = KeyOnlyProvider::new(handle, &key);
|
let mut provider = KeyOnlyProvider::new(handle, &key);
|
||||||
let mut page = MobileTotpService::new(&self.repository, &self.keys)
|
let service = MobileTotpService::new(&self.repository, &self.keys);
|
||||||
.discover(&shared, &mut provider, &cache_path, operation)
|
let mut page = if rebuild {
|
||||||
|
service.rebuild(&shared, &mut provider, &cache_path, operation)
|
||||||
|
} else {
|
||||||
|
service.discover(&shared, &mut provider, &cache_path, operation)
|
||||||
|
}
|
||||||
.map_err(totp_error)?;
|
.map_err(totp_error)?;
|
||||||
page.set_watch(self.watch_snapshot_status()?);
|
page.set_watch(self.watch_snapshot_status()?);
|
||||||
Ok(page)
|
Ok(page)
|
||||||
@@ -1826,15 +1918,6 @@ impl MobileAuthentication {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn delete_all_key_passphrases(&self) -> Result<(), MobileAuthenticationError> {
|
|
||||||
for key in self.keys.infos().filter(KeyInfo::has_secret) {
|
|
||||||
self.session
|
|
||||||
.delete_key_passphrase(&key)
|
|
||||||
.map_err(MobileAuthenticationError::authentication)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status(
|
fn status(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<std::sync::MutexGuard<'_, MobileAuthenticationStatus>, MobileAuthenticationError>
|
) -> Result<std::sync::MutexGuard<'_, MobileAuthenticationStatus>, MobileAuthenticationError>
|
||||||
@@ -2399,6 +2482,27 @@ mod tests {
|
|||||||
);
|
);
|
||||||
authentication.manual_lock().unwrap();
|
authentication.manual_lock().unwrap();
|
||||||
assert!(authentication.restore_biometric_lease().unwrap().unlocked());
|
assert!(authentication.restore_biometric_lease().unwrap().unlocked());
|
||||||
|
let config = private.configuration().unwrap().unwrap();
|
||||||
|
let original = std::fs::read(config.source()).unwrap();
|
||||||
|
std::fs::write(config.source(), b"damaged settings [").unwrap();
|
||||||
|
assert!(authentication.set_biometric_unlock(false).is_err());
|
||||||
|
assert!(authentication.state().unwrap().biometric_unlock_enabled());
|
||||||
|
std::fs::write(config.source(), original).unwrap();
|
||||||
|
authentication.manual_lock().unwrap();
|
||||||
|
assert!(authentication.restore_biometric_lease().unwrap().unlocked());
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let parent = config.source().parent().unwrap();
|
||||||
|
let permissions = std::fs::metadata(parent).unwrap().permissions();
|
||||||
|
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||||
|
let result = authentication.set_biometric_unlock(false);
|
||||||
|
std::fs::set_permissions(parent, permissions).unwrap();
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(authentication.state().unwrap().biometric_unlock_enabled());
|
||||||
|
authentication.manual_lock().unwrap();
|
||||||
|
assert!(authentication.restore_biometric_lease().unwrap().unlocked());
|
||||||
|
}
|
||||||
authentication.set_biometric_unlock(false).unwrap();
|
authentication.set_biometric_unlock(false).unwrap();
|
||||||
assert!(authentication.restore_biometric_lease().is_err());
|
assert!(authentication.restore_biometric_lease().is_err());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,10 +575,8 @@ struct MobileHomeStorage {
|
|||||||
impl MobileHomeStorage {
|
impl MobileHomeStorage {
|
||||||
fn load(private: Option<&PrivateHomeContext>) -> Result<Self, MobileHomeError> {
|
fn load(private: Option<&PrivateHomeContext>) -> Result<Self, MobileHomeError> {
|
||||||
let (config, backend, lock) = if let Some(private) = private {
|
let (config, backend, lock) = if let Some(private) = private {
|
||||||
private
|
// with_private verified binding through the shared authentication owner; repeating
|
||||||
.storage
|
// it here would lock that owner's active entry/settings lease.
|
||||||
.verify_secure_storage(private.backend.as_ref())
|
|
||||||
.map_err(MobileHomeError::from_private)?;
|
|
||||||
let root = cap_std::fs::Dir::open_ambient_dir(
|
let root = cap_std::fs::Dir::open_ambient_dir(
|
||||||
private.storage.root(),
|
private.storage.root(),
|
||||||
cap_std::ambient_authority(),
|
cap_std::ambient_authority(),
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ fn operation(private: &Arc<PrivateStorage>, backend: &Arc<Backend>) -> MobileHom
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn private_home_reads_do_not_lock_the_shared_settings_authentication_backend() {
|
||||||
|
let (_temp, private, backend) = connected();
|
||||||
|
let home = operation(&private, &backend);
|
||||||
|
let locks = backend.locks.load(Ordering::Relaxed);
|
||||||
|
assert!(home.identity().is_ok());
|
||||||
|
assert!(home.cached().is_ok());
|
||||||
|
assert!(operation(&private, &backend).cached().is_ok());
|
||||||
|
assert_eq!(backend.locks.load(Ordering::Relaxed), locks);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn private_home_keeps_transient_private_storage_retry_guidance() {
|
fn private_home_keeps_transient_private_storage_retry_guidance() {
|
||||||
let (_temp, private, backend) = connected();
|
let (_temp, private, backend) = connected();
|
||||||
|
|||||||
@@ -217,6 +217,15 @@ impl MobileKeyTransferImport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_image(
|
||||||
|
&mut self,
|
||||||
|
image: SecretBytes,
|
||||||
|
) -> Result<MobileKeyTransferProgress, MobileKeyTransferError> {
|
||||||
|
let payload =
|
||||||
|
QrMatrix::decode_image(&image).map_err(|_| MobileKeyTransferError::InvalidFrame)?;
|
||||||
|
self.add_frame(payload)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add_frame(
|
pub fn add_frame(
|
||||||
&mut self,
|
&mut self,
|
||||||
payload: SecretBytes,
|
payload: SecretBytes,
|
||||||
@@ -352,6 +361,32 @@ impl MobileKeyTransferImport {
|
|||||||
return Err(MobileKeyTransferError::ExistingKey);
|
return Err(MobileKeyTransferError::ExistingKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let current = Config::load(Some(config.source()))?;
|
||||||
|
let candidate = if make_default {
|
||||||
|
let mut settings = current.settings();
|
||||||
|
settings.set_default_key(key.fingerprint.clone());
|
||||||
|
Some(current.with_settings(settings)?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let target = if config.key_material().is_dir() {
|
||||||
|
let suffix = if key.kind == MobileKeyTransferKind::Private {
|
||||||
|
"secret"
|
||||||
|
} else {
|
||||||
|
"public"
|
||||||
|
};
|
||||||
|
config.key_material().join(format!(
|
||||||
|
"{}-{suffix}.asc",
|
||||||
|
key.fingerprint.to_ascii_lowercase()
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
config.key_material().to_owned()
|
||||||
|
};
|
||||||
|
let backup = match fs::read(&target) {
|
||||||
|
Ok(value) => Some(SecretBytes::new(value)),
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||||
|
Err(_) => return Err(MobileKeyTransferError::Write),
|
||||||
|
};
|
||||||
persist_armored_key(
|
persist_armored_key(
|
||||||
config.key_material(),
|
config.key_material(),
|
||||||
&key.fingerprint,
|
&key.fingerprint,
|
||||||
@@ -359,10 +394,21 @@ impl MobileKeyTransferImport {
|
|||||||
armor.expose(),
|
armor.expose(),
|
||||||
overwrite,
|
overwrite,
|
||||||
)?;
|
)?;
|
||||||
if make_default {
|
if let Some(candidate) = candidate
|
||||||
let mut settings = config.settings();
|
&& let Err(error) = candidate.persist()
|
||||||
settings.set_default_key(key.fingerprint.clone());
|
{
|
||||||
config.with_settings(settings)?.persist()?;
|
if !matches!(error, ConfigError::DurabilityUncertain { .. }) {
|
||||||
|
let parent = target.parent().ok_or(MobileKeyTransferError::Write)?;
|
||||||
|
let name = Path::new(target.file_name().ok_or(MobileKeyTransferError::Write)?);
|
||||||
|
let restored = match backup {
|
||||||
|
Some(ref value) => atomic_replace(parent, name, value.expose()),
|
||||||
|
None => fs::remove_file(&target)
|
||||||
|
.and_then(|()| fs::File::open(parent)?.sync_all())
|
||||||
|
.map_err(|_| MobileKeyTransferError::Write),
|
||||||
|
};
|
||||||
|
restored.map_err(|_| MobileKeyTransferError::RecoveryRequired)?;
|
||||||
|
}
|
||||||
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
let kind = if key.kind == MobileKeyTransferKind::Private {
|
let kind = if key.kind == MobileKeyTransferKind::Private {
|
||||||
"private"
|
"private"
|
||||||
@@ -421,12 +467,14 @@ pub enum MobileKeyTransferError {
|
|||||||
IncorrectPassphrase,
|
IncorrectPassphrase,
|
||||||
QrPayload,
|
QrPayload,
|
||||||
Write,
|
Write,
|
||||||
|
RecoveryRequired,
|
||||||
LocalSetup(crate::mobile_onboarding::MobileOnboardingError),
|
LocalSetup(crate::mobile_onboarding::MobileOnboardingError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for MobileKeyTransferError {
|
impl fmt::Display for MobileKeyTransferError {
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
formatter.write_str(match self {
|
formatter.write_str(match self {
|
||||||
|
Self::RecoveryRequired => "key import could not be rolled back; retain the key material and recover configuration before retrying",
|
||||||
Self::Configuration => "the IronStorage configuration is unavailable",
|
Self::Configuration => "the IronStorage configuration is unavailable",
|
||||||
Self::KeyUnavailable => "the selected GPG key is unavailable",
|
Self::KeyUnavailable => "the selected GPG key is unavailable",
|
||||||
Self::InvalidFrame => "this is not a valid IronStorage key-transfer QR code",
|
Self::InvalidFrame => "this is not a valid IronStorage key-transfer QR code",
|
||||||
@@ -666,6 +714,35 @@ mod tests {
|
|||||||
include_bytes!("../tests/fixtures/compatibility/keys/alice-secret.asc");
|
include_bytes!("../tests/fixtures/compatibility/keys/alice-secret.asc");
|
||||||
const ALICE_FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
const ALICE_FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn qr_images_reconstruct_the_same_key_and_reject_invalid_images()
|
||||||
|
-> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let fixture = configured_public_key()?;
|
||||||
|
let mut importer = MobileKeyTransferImport::new(fixture.config);
|
||||||
|
assert_eq!(
|
||||||
|
importer.add_image(SecretBytes::new(b"not an image".to_vec())),
|
||||||
|
Err(MobileKeyTransferError::InvalidFrame)
|
||||||
|
);
|
||||||
|
for frame in encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)? {
|
||||||
|
let matrix = frame.matrix();
|
||||||
|
let size = u32::try_from(matrix.width() + 8)? * 4;
|
||||||
|
let image = image::GrayImage::from_fn(size, size, |x, y| {
|
||||||
|
let x = (x / 4) as usize;
|
||||||
|
let y = (y / 4) as usize;
|
||||||
|
let dark = x >= 4 && y >= 4 && matrix.is_dark(x - 4, y - 4).unwrap_or(false);
|
||||||
|
image::Luma([if dark { 0 } else { 255 }])
|
||||||
|
});
|
||||||
|
let mut png = std::io::Cursor::new(Vec::new());
|
||||||
|
image.write_to(&mut png, image::ImageFormat::Png)?;
|
||||||
|
importer.add_image(SecretBytes::new(png.into_inner()))?;
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
importer.complete.as_ref().map(SecretBytes::expose),
|
||||||
|
Some(ALICE_SECRET)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multipart_frames_reconstruct_exact_armor_out_of_order()
|
fn multipart_frames_reconstruct_exact_armor_out_of_order()
|
||||||
-> Result<(), Box<dyn std::error::Error>> {
|
-> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -773,11 +850,23 @@ mod tests {
|
|||||||
Err(MobileKeyTransferError::IncorrectPassphrase)
|
Err(MobileKeyTransferError::IncorrectPassphrase)
|
||||||
);
|
);
|
||||||
assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1);
|
assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1);
|
||||||
|
fixture
|
||||||
|
.config
|
||||||
|
.update_git_identity(&crate::git::GitIdentity::new(
|
||||||
|
"Changed since QR opened",
|
||||||
|
"changed@example.test",
|
||||||
|
)?)?;
|
||||||
importer.import(
|
importer.import(
|
||||||
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
)?;
|
)?;
|
||||||
|
assert_eq!(
|
||||||
|
Config::load(Some(fixture.config.source()))?
|
||||||
|
.git_identity()
|
||||||
|
.name(),
|
||||||
|
"Changed since QR opened"
|
||||||
|
);
|
||||||
let keys = KeyStore::load(&fixture.key_path)?;
|
let keys = KeyStore::load(&fixture.key_path)?;
|
||||||
assert!(keys.infos().any(|key| key.has_secret()));
|
assert!(keys.infos().any(|key| key.has_secret()));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -815,6 +904,49 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn default_key_import_rolls_back_when_config_cannot_be_replaced()
|
||||||
|
-> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let fixture = configured_public_key()?;
|
||||||
|
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
|
||||||
|
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||||
|
let before = std::fs::read(fixture.config.source())?;
|
||||||
|
let parent = fixture.config.source().parent().unwrap();
|
||||||
|
let permissions = std::fs::metadata(parent)?.permissions();
|
||||||
|
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o500))?;
|
||||||
|
let result = importer.import(
|
||||||
|
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
std::fs::set_permissions(parent, permissions)?;
|
||||||
|
assert_eq!(result, Err(MobileKeyTransferError::Configuration));
|
||||||
|
assert_eq!(std::fs::read(fixture.config.source())?, before);
|
||||||
|
assert_eq!(std::fs::read_dir(fixture.key_path)?.count(), 1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_configuration_does_not_install_an_imported_key()
|
||||||
|
-> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let fixture = configured_public_key()?;
|
||||||
|
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
|
||||||
|
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
||||||
|
std::fs::write(fixture.config.source(), b"not a TOML document [")?;
|
||||||
|
assert_eq!(
|
||||||
|
importer.import(
|
||||||
|
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
||||||
|
true,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
Err(MobileKeyTransferError::Configuration)
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read_dir(fixture.key_path)?.count(), 1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cloned_store_with_empty_key_directory_can_import_its_private_key()
|
fn cloned_store_with_empty_key_directory_can_import_its_private_key()
|
||||||
-> Result<(), Box<dyn std::error::Error>> {
|
-> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|||||||
@@ -912,8 +912,13 @@ pub(crate) mod tests {
|
|||||||
pub(crate) struct Backend {
|
pub(crate) struct Backend {
|
||||||
records: Mutex<BTreeMap<SecretLocator, Vec<u8>>>,
|
records: Mutex<BTreeMap<SecretLocator, Vec<u8>>>,
|
||||||
fail_tokens: AtomicBool,
|
fail_tokens: AtomicBool,
|
||||||
|
pub(crate) locks: std::sync::atomic::AtomicUsize,
|
||||||
}
|
}
|
||||||
impl SecretStoreBackend for Backend {
|
impl SecretStoreBackend for Backend {
|
||||||
|
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||||
|
self.locks.fetch_add(1, Ordering::Relaxed);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
fn create(
|
fn create(
|
||||||
&self,
|
&self,
|
||||||
locator: &SecretLocator,
|
locator: &SecretLocator,
|
||||||
@@ -1537,10 +1542,13 @@ pub(crate) mod tests {
|
|||||||
use crate::mobile_home::{MobileHomeActionKind, MobileHomeFreshness, MobileHomePhase};
|
use crate::mobile_home::{MobileHomeActionKind, MobileHomeFreshness, MobileHomePhase};
|
||||||
let (_temp, private, backend) = home_fixture();
|
let (_temp, private, backend) = home_fixture();
|
||||||
let config = private.configuration().unwrap().unwrap();
|
let config = private.configuration().unwrap().unwrap();
|
||||||
config
|
private
|
||||||
.update_git_identity(
|
.authentication(
|
||||||
&GitIdentity::new("Android fixture", "fixture@example.test").unwrap(),
|
backend.clone(),
|
||||||
|
Arc::new(crate::authentication::SystemAuthenticationClock::default()),
|
||||||
)
|
)
|
||||||
|
.unwrap()
|
||||||
|
.set_git_identity("Android fixture".into(), "fixture@example.test".into())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
config.vault().join("仕事.gpg"),
|
config.vault().join("仕事.gpg"),
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ pub struct PrivateStorage {
|
|||||||
cache: Dir,
|
cache: Dir,
|
||||||
status: Mutex<PrivateStorageStatus>,
|
status: Mutex<PrivateStorageStatus>,
|
||||||
authentication: Mutex<Option<Arc<MobileAuthentication>>>,
|
authentication: Mutex<Option<Arc<MobileAuthentication>>>,
|
||||||
|
android_backend: Mutex<Option<Arc<crate::secret_store::android::AndroidSecretBackend>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PrivateStorage {
|
impl PrivateStorage {
|
||||||
@@ -233,6 +234,7 @@ impl PrivateStorage {
|
|||||||
let root = roots.no_backup.join("ironstorage");
|
let root = roots.no_backup.join("ironstorage");
|
||||||
let mut storage = Self {
|
let mut storage = Self {
|
||||||
authentication: Mutex::new(None),
|
authentication: Mutex::new(None),
|
||||||
|
android_backend: Mutex::new(None),
|
||||||
root,
|
root,
|
||||||
cache_path: roots.cache.join("ironstorage"),
|
cache_path: roots.cache.join("ironstorage"),
|
||||||
data,
|
data,
|
||||||
@@ -283,10 +285,31 @@ impl PrivateStorage {
|
|||||||
pub fn android_secret_backend(
|
pub fn android_secret_backend(
|
||||||
&self,
|
&self,
|
||||||
keystore: std::sync::Arc<dyn crate::secret_store::android::AndroidKeystore>,
|
keystore: std::sync::Arc<dyn crate::secret_store::android::AndroidKeystore>,
|
||||||
) -> Result<crate::secret_store::android::AndroidSecretBackend, PrivateStorageError> {
|
) -> Result<Arc<crate::secret_store::android::AndroidSecretBackend>, PrivateStorageError> {
|
||||||
|
let mut owner = self
|
||||||
|
.android_backend
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| PrivateStorageError::Busy)?;
|
||||||
|
let enabled = self
|
||||||
|
.configuration()
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_none_or(|config| config.biometric_unlock_enabled());
|
||||||
|
if let Some(backend) = owner.as_ref() {
|
||||||
|
backend
|
||||||
|
.verify_key_inventory(enabled)
|
||||||
|
.map_err(secret_error)?;
|
||||||
|
return Ok(Arc::clone(backend));
|
||||||
|
}
|
||||||
let directory = private_directory(&self.data, "secrets")?;
|
let directory = private_directory(&self.data, "secrets")?;
|
||||||
crate::secret_store::android::AndroidSecretBackend::open(directory, keystore)
|
let backend = Arc::new(
|
||||||
.map_err(secret_error)
|
crate::secret_store::android::AndroidSecretBackend::open_with_biometry(
|
||||||
|
directory, keystore, enabled,
|
||||||
|
)
|
||||||
|
.map_err(secret_error)?,
|
||||||
|
);
|
||||||
|
*owner = Some(Arc::clone(&backend));
|
||||||
|
Ok(backend)
|
||||||
}
|
}
|
||||||
pub fn root(&self) -> &Path {
|
pub fn root(&self) -> &Path {
|
||||||
&self.root
|
&self.root
|
||||||
@@ -304,26 +327,53 @@ impl PrivateStorage {
|
|||||||
error.detail(),
|
error.detail(),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
self.verify_secure_storage(backend.as_ref())
|
// Retain the native backend's encrypted transactions across the FFI callback adapter.
|
||||||
.map_err(recovery)?;
|
let backend: Arc<dyn SecretStoreBackend> = match self
|
||||||
let config = self
|
.android_backend
|
||||||
.configuration()
|
.lock()
|
||||||
.map_err(recovery)?
|
.map_err(|_| recovery(PrivateStorageError::Busy))?
|
||||||
.ok_or_else(|| recovery(PrivateStorageError::ConfigurationMissingWithData))?;
|
.as_ref()
|
||||||
|
{
|
||||||
|
Some(native) => native.clone(),
|
||||||
|
None => backend,
|
||||||
|
};
|
||||||
let mut owner = self
|
let mut owner = self
|
||||||
.authentication
|
.authentication
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|_| recovery(PrivateStorageError::Busy))?;
|
.map_err(|_| recovery(PrivateStorageError::Busy))?;
|
||||||
|
let config = self
|
||||||
|
.configuration()
|
||||||
|
.map_err(recovery)?
|
||||||
|
.ok_or_else(|| recovery(PrivateStorageError::ConfigurationMissingWithData))?;
|
||||||
if let Some(current) = owner.as_ref() {
|
if let Some(current) = owner.as_ref() {
|
||||||
if current.matches_configuration(&config)? {
|
if current.matches_configuration(&config)? {
|
||||||
return Ok(Arc::clone(current));
|
return Ok(Arc::clone(current));
|
||||||
}
|
}
|
||||||
current.manual_lock()?;
|
current.manual_lock()?;
|
||||||
}
|
}
|
||||||
|
// Binding verification locks the backend, so run it only without a live lease.
|
||||||
|
self.verify_secure_storage(backend.as_ref())
|
||||||
|
.map_err(recovery)?;
|
||||||
let current = Arc::new(MobileAuthentication::with_platform(config, backend, clock)?);
|
let current = Arc::new(MobileAuthentication::with_platform(config, backend, clock)?);
|
||||||
*owner = Some(Arc::clone(¤t));
|
*owner = Some(Arc::clone(¤t));
|
||||||
Ok(current)
|
Ok(current)
|
||||||
}
|
}
|
||||||
|
/// Imported/replaced keys must revoke the old owner before the next configuration load.
|
||||||
|
pub fn key_material_changed(&self) -> Result<(), MobileAuthenticationError> {
|
||||||
|
let mut owner = self.authentication.lock().map_err(|_| {
|
||||||
|
MobileAuthenticationError::new(
|
||||||
|
MobileAuthenticationErrorKind::SecureStorage,
|
||||||
|
"Authentication Is Unavailable",
|
||||||
|
"The authentication owner could not be refreshed.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if let Some(current) = owner.as_ref() {
|
||||||
|
current.lifecycle(crate::mobile_platform::MobileLifecycleEvent::ProcessRecreated)?;
|
||||||
|
}
|
||||||
|
*owner = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Every existing default-path API now uses this explicit OS context, never HOME/cwd.
|
/// Every existing default-path API now uses this explicit OS context, never HOME/cwd.
|
||||||
pub fn activate(&self) -> Result<(), PrivateStorageError> {
|
pub fn activate(&self) -> Result<(), PrivateStorageError> {
|
||||||
if crate::config::ConfigLoader::activate_private_root(&self.root) {
|
if crate::config::ConfigLoader::activate_private_root(&self.root) {
|
||||||
|
|||||||
@@ -344,6 +344,27 @@ impl<'a> MobileTotpService<'a> {
|
|||||||
provider: &mut impl SecretProvider,
|
provider: &mut impl SecretProvider,
|
||||||
cache_path: &Path,
|
cache_path: &Path,
|
||||||
operation: &MobileTotpOperation,
|
operation: &MobileTotpOperation,
|
||||||
|
) -> Result<MobileTotpPage, MobileTotpError> {
|
||||||
|
self.discover_with_cache(shared, provider, cache_path, operation, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rebuild(
|
||||||
|
&self,
|
||||||
|
shared: &BTreeSet<EntryPath>,
|
||||||
|
provider: &mut impl SecretProvider,
|
||||||
|
cache_path: &Path,
|
||||||
|
operation: &MobileTotpOperation,
|
||||||
|
) -> Result<MobileTotpPage, MobileTotpError> {
|
||||||
|
self.discover_with_cache(shared, provider, cache_path, operation, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn discover_with_cache(
|
||||||
|
&self,
|
||||||
|
shared: &BTreeSet<EntryPath>,
|
||||||
|
provider: &mut impl SecretProvider,
|
||||||
|
cache_path: &Path,
|
||||||
|
operation: &MobileTotpOperation,
|
||||||
|
reuse_cache: bool,
|
||||||
) -> Result<MobileTotpPage, MobileTotpError> {
|
) -> Result<MobileTotpPage, MobileTotpError> {
|
||||||
operation.check_cancelled()?;
|
operation.check_cancelled()?;
|
||||||
let initial_inventory = inventory(self.repository)?;
|
let initial_inventory = inventory(self.repository)?;
|
||||||
@@ -353,7 +374,10 @@ impl<'a> MobileTotpService<'a> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let store = store_identity(self.repository.root_path(), cache_path);
|
let store = store_identity(self.repository.root_path(), cache_path);
|
||||||
let (cached, mut cache_notice) = load_cache(cache_path, &store);
|
let (mut cached, mut cache_notice) = load_cache(cache_path, &store);
|
||||||
|
if !reuse_cache {
|
||||||
|
cached = None;
|
||||||
|
}
|
||||||
let mut records = BTreeMap::new();
|
let mut records = BTreeMap::new();
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut unavailable_entries = 0_u32;
|
let mut unavailable_entries = 0_u32;
|
||||||
@@ -423,6 +447,34 @@ impl<'a> MobileTotpService<'a> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cache_status(&self, cache_path: &Path) -> (u32, String) {
|
||||||
|
let store = store_identity(self.repository.root_path(), cache_path);
|
||||||
|
let (catalog, notice) = load_cache(cache_path, &store);
|
||||||
|
if let Some(notice) = notice {
|
||||||
|
return (0, notice);
|
||||||
|
}
|
||||||
|
match catalog {
|
||||||
|
None => (
|
||||||
|
0,
|
||||||
|
"No TOTP discovery cache. Unlock and rebuild to inspect the store.".to_owned(),
|
||||||
|
),
|
||||||
|
Some(catalog) => {
|
||||||
|
let count = self
|
||||||
|
.cached_page(&BTreeSet::new(), cache_path)
|
||||||
|
.map_or(0, |page| {
|
||||||
|
u32::try_from(page.rows().len()).unwrap_or(u32::MAX)
|
||||||
|
});
|
||||||
|
(
|
||||||
|
count,
|
||||||
|
format!(
|
||||||
|
"{count} current TOTP entries; {} passwords inspected. Rebuild to check changed or new entries.",
|
||||||
|
catalog.entries.len()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cached_page(
|
pub fn cached_page(
|
||||||
&self,
|
&self,
|
||||||
shared: &BTreeSet<EntryPath>,
|
shared: &BTreeSet<EntryPath>,
|
||||||
|
|||||||
@@ -368,6 +368,41 @@ pub trait SecretStoreBackend: Send + Sync {
|
|||||||
locator: &SecretLocator,
|
locator: &SecretLocator,
|
||||||
protection: SecretProtection,
|
protection: SecretProtection,
|
||||||
) -> Result<(), SecretStoreError>;
|
) -> Result<(), SecretStoreError>;
|
||||||
|
/// Remove a set of protected records together with a storage-owned setting.
|
||||||
|
/// Keep rollback material inside the backend; never return it to a frontend.
|
||||||
|
fn delete_transaction(
|
||||||
|
&self,
|
||||||
|
locators: &[SecretLocator],
|
||||||
|
protection: SecretProtection,
|
||||||
|
commit: &mut dyn FnMut() -> Result<(), SecretStoreError>,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let mut backup = Vec::new();
|
||||||
|
for locator in locators {
|
||||||
|
match self.retrieve(locator, protection) {
|
||||||
|
Ok(value) => backup.push((locator, value)),
|
||||||
|
Err(SecretStoreError::Missing) => {}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = (|| {
|
||||||
|
for (locator, _) in &backup {
|
||||||
|
self.delete(locator, protection)?;
|
||||||
|
}
|
||||||
|
commit()
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
for (locator, value) in &backup {
|
||||||
|
match self.replace(locator, protection, value.expose()) {
|
||||||
|
Ok(()) => {}
|
||||||
|
Err(SecretStoreError::Missing) => {
|
||||||
|
self.create(locator, protection, value.expose())?
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
fn platform_https_git_credential(
|
fn platform_https_git_credential(
|
||||||
&self,
|
&self,
|
||||||
_endpoint: &url::Url,
|
_endpoint: &url::Url,
|
||||||
@@ -413,6 +448,14 @@ impl<T: SecretStoreBackend + ?Sized> SecretStoreBackend for std::sync::Arc<T> {
|
|||||||
) -> Result<(), SecretStoreError> {
|
) -> Result<(), SecretStoreError> {
|
||||||
(**self).delete(locator, protection)
|
(**self).delete(locator, protection)
|
||||||
}
|
}
|
||||||
|
fn delete_transaction(
|
||||||
|
&self,
|
||||||
|
locators: &[SecretLocator],
|
||||||
|
protection: SecretProtection,
|
||||||
|
commit: &mut dyn FnMut() -> Result<(), SecretStoreError>,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
(**self).delete_transaction(locators, protection, commit)
|
||||||
|
}
|
||||||
fn platform_https_git_credential(
|
fn platform_https_git_credential(
|
||||||
&self,
|
&self,
|
||||||
endpoint: &url::Url,
|
endpoint: &url::Url,
|
||||||
@@ -723,6 +766,30 @@ impl<B: SecretStoreBackend> SecretStore<B> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn delete_openpgp_transaction(
|
||||||
|
&self,
|
||||||
|
references: &[SecretReference],
|
||||||
|
commit: &mut dyn FnMut() -> Result<(), SecretStoreError>,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let mut state = self.unlocked_state()?;
|
||||||
|
let mut locators = Vec::with_capacity(references.len());
|
||||||
|
for reference in references {
|
||||||
|
if !matches!(
|
||||||
|
reference.kind,
|
||||||
|
SecretReferenceKind::OpenPgpPassphrase { .. }
|
||||||
|
) {
|
||||||
|
return Err(SecretStoreError::InvalidReference);
|
||||||
|
}
|
||||||
|
locators.push(reference.locator());
|
||||||
|
}
|
||||||
|
self.backend
|
||||||
|
.delete_transaction(&locators, self.protections.openpgp, commit)?;
|
||||||
|
for locator in locators {
|
||||||
|
state.cache.remove(&locator);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn retrieve_git_record(
|
fn retrieve_git_record(
|
||||||
&self,
|
&self,
|
||||||
server: &ServerId,
|
server: &ServerId,
|
||||||
|
|||||||
@@ -124,9 +124,14 @@ pub struct AndroidSecretBackend {
|
|||||||
generation: AtomicU64,
|
generation: AtomicU64,
|
||||||
}
|
}
|
||||||
impl AndroidSecretBackend {
|
impl AndroidSecretBackend {
|
||||||
pub(crate) fn open(
|
#[cfg(test)]
|
||||||
|
fn open(directory: Dir, keystore: Arc<dyn AndroidKeystore>) -> Result<Self, SecretStoreError> {
|
||||||
|
Self::open_with_biometry(directory, keystore, true)
|
||||||
|
}
|
||||||
|
pub(crate) fn open_with_biometry(
|
||||||
directory: Dir,
|
directory: Dir,
|
||||||
keystore: Arc<dyn AndroidKeystore>,
|
keystore: Arc<dyn AndroidKeystore>,
|
||||||
|
biometric_enabled: bool,
|
||||||
) -> Result<Self, SecretStoreError> {
|
) -> Result<Self, SecretStoreError> {
|
||||||
let backend = Self {
|
let backend = Self {
|
||||||
directory,
|
directory,
|
||||||
@@ -135,10 +140,19 @@ impl AndroidSecretBackend {
|
|||||||
generation: AtomicU64::new(0),
|
generation: AtomicU64::new(0),
|
||||||
};
|
};
|
||||||
let _lock = backend.transaction()?;
|
let _lock = backend.transaction()?;
|
||||||
|
backend.recover_biometric_removal(biometric_enabled)?;
|
||||||
backend.cleanup_orphan_keys()?;
|
backend.cleanup_orphan_keys()?;
|
||||||
drop(_lock);
|
drop(_lock);
|
||||||
Ok(backend)
|
Ok(backend)
|
||||||
}
|
}
|
||||||
|
pub(crate) fn verify_key_inventory(
|
||||||
|
&self,
|
||||||
|
biometric_enabled: bool,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
let _lock = self.transaction()?;
|
||||||
|
self.recover_biometric_removal(biometric_enabled)?;
|
||||||
|
self.cleanup_orphan_keys()
|
||||||
|
}
|
||||||
fn transaction(&self) -> Result<std::fs::File, SecretStoreError> {
|
fn transaction(&self) -> Result<std::fs::File, SecretStoreError> {
|
||||||
let mut options = OpenOptions::new();
|
let mut options = OpenOptions::new();
|
||||||
options.read(true).write(true).create(true);
|
options.read(true).write(true).create(true);
|
||||||
@@ -198,8 +212,10 @@ impl AndroidSecretBackend {
|
|||||||
self.authorization(protection).map(|_| ())
|
self.authorization(protection).map(|_| ())
|
||||||
}
|
}
|
||||||
fn read(&self, id: &str) -> Result<Option<Envelope>, SecretStoreError> {
|
fn read(&self, id: &str) -> Result<Option<Envelope>, SecretStoreError> {
|
||||||
let name = format!("{id}.record");
|
self.read_named(id, &format!("{id}.record"))
|
||||||
let metadata = match self.directory.symlink_metadata(&name) {
|
}
|
||||||
|
fn read_named(&self, id: &str, name: &str) -> Result<Option<Envelope>, SecretStoreError> {
|
||||||
|
let metadata = match self.directory.symlink_metadata(name) {
|
||||||
Ok(metadata) => metadata,
|
Ok(metadata) => metadata,
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||||
Err(error) => return Err(io_error(error)),
|
Err(error) => return Err(io_error(error)),
|
||||||
@@ -210,7 +226,7 @@ impl AndroidSecretBackend {
|
|||||||
{
|
{
|
||||||
return Err(SecretStoreError::Corrupted);
|
return Err(SecretStoreError::Corrupted);
|
||||||
}
|
}
|
||||||
let file = self.directory.open(&name).map_err(io_error)?;
|
let file = self.directory.open(name).map_err(io_error)?;
|
||||||
crate::mobile_private_storage::private_permissions(&file, false)
|
crate::mobile_private_storage::private_permissions(&file, false)
|
||||||
.map_err(|_| SecretStoreError::Unavailable)?;
|
.map_err(|_| SecretStoreError::Unavailable)?;
|
||||||
let mut bytes = Vec::new();
|
let mut bytes = Vec::new();
|
||||||
@@ -219,6 +235,41 @@ impl AndroidSecretBackend {
|
|||||||
.map_err(io_error)?;
|
.map_err(io_error)?;
|
||||||
decode(id, &bytes).map(Some)
|
decode(id, &bytes).map(Some)
|
||||||
}
|
}
|
||||||
|
fn recover_biometric_removal(&self, enabled: bool) -> Result<(), SecretStoreError> {
|
||||||
|
for (index, entry) in self.directory.entries().map_err(io_error)?.enumerate() {
|
||||||
|
if index > MAX_RECORDS {
|
||||||
|
return Err(SecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
let name = entry.map_err(io_error)?.file_name();
|
||||||
|
let name = name.to_str().ok_or(SecretStoreError::Corrupted)?;
|
||||||
|
if let Some(id) = name.strip_suffix(".removing") {
|
||||||
|
if !valid_id(id) {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let envelope = self
|
||||||
|
.read_named(id, name)?
|
||||||
|
.ok_or(SecretStoreError::Corrupted)?;
|
||||||
|
if envelope.protection != SecretProtection::BiometryCurrentSet {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
let original = format!("{id}.record");
|
||||||
|
if self.directory.symlink_metadata(&original).is_ok() {
|
||||||
|
return Err(SecretStoreError::Corrupted);
|
||||||
|
}
|
||||||
|
if enabled {
|
||||||
|
self.directory
|
||||||
|
.rename(name, &self.directory, original)
|
||||||
|
.map_err(io_error)?;
|
||||||
|
} else {
|
||||||
|
self.directory.remove_file(name).map_err(io_error)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.directory
|
||||||
|
.open(".")
|
||||||
|
.and_then(|file| file.sync_all())
|
||||||
|
.map_err(io_error)
|
||||||
|
}
|
||||||
fn cleanup_orphan_keys(&self) -> Result<(), SecretStoreError> {
|
fn cleanup_orphan_keys(&self) -> Result<(), SecretStoreError> {
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
let _creation = crate::file_lock::directory(&self.directory).map_err(io_error)?;
|
let _creation = crate::file_lock::directory(&self.directory).map_err(io_error)?;
|
||||||
@@ -420,6 +471,70 @@ impl SecretStoreBackend for AndroidSecretBackend {
|
|||||||
.map_err(SecretStoreError::from);
|
.map_err(SecretStoreError::from);
|
||||||
durability.and(deleted)
|
durability.and(deleted)
|
||||||
}
|
}
|
||||||
|
fn delete_transaction(
|
||||||
|
&self,
|
||||||
|
locators: &[SecretLocator],
|
||||||
|
protection: SecretProtection,
|
||||||
|
commit: &mut dyn FnMut() -> Result<(), SecretStoreError>,
|
||||||
|
) -> Result<(), SecretStoreError> {
|
||||||
|
if protection != SecretProtection::BiometryCurrentSet {
|
||||||
|
return Err(SecretStoreError::UnsupportedProtection);
|
||||||
|
}
|
||||||
|
let _lock = self.transaction()?;
|
||||||
|
let mut names = Vec::new();
|
||||||
|
for locator in locators {
|
||||||
|
validate_protection(locator, protection)?;
|
||||||
|
let id = identity(locator)?;
|
||||||
|
if self
|
||||||
|
.directory
|
||||||
|
.symlink_metadata(format!("{id}.removing"))
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Err(SecretStoreError::Unavailable);
|
||||||
|
}
|
||||||
|
if let Some(envelope) = self.read(&id)? {
|
||||||
|
if envelope.protection != protection {
|
||||||
|
return Err(SecretStoreError::UnsupportedProtection);
|
||||||
|
}
|
||||||
|
names.push((format!("{id}.record"), format!("{id}.removing")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The live Rust setting decides rollback or completion after process death.
|
||||||
|
let result = (|| {
|
||||||
|
for (original, pending) in &names {
|
||||||
|
self.directory
|
||||||
|
.rename(original, &self.directory, pending)
|
||||||
|
.map_err(io_error)?;
|
||||||
|
}
|
||||||
|
self.directory
|
||||||
|
.open(".")
|
||||||
|
.and_then(|file| file.sync_all())
|
||||||
|
.map_err(io_error)?;
|
||||||
|
commit()
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
for (original, pending) in &names {
|
||||||
|
if self.directory.symlink_metadata(pending).is_ok() {
|
||||||
|
self.directory
|
||||||
|
.rename(pending, &self.directory, original)
|
||||||
|
.map_err(io_error)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (_, pending) in &names {
|
||||||
|
self.directory.remove_file(pending).map_err(io_error)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.directory
|
||||||
|
.open(".")
|
||||||
|
.and_then(|file| file.sync_all())
|
||||||
|
.map_err(io_error)?;
|
||||||
|
if result.is_ok() {
|
||||||
|
// No ciphertext remains for these aliases; startup retries their OS cleanup.
|
||||||
|
let _ = self.cleanup_orphan_keys();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
fn lock(&self) -> Result<(), SecretStoreError> {
|
fn lock(&self) -> Result<(), SecretStoreError> {
|
||||||
self.locked.store(true, Ordering::Release);
|
self.locked.store(true, Ordering::Release);
|
||||||
self.generation.fetch_add(1, Ordering::AcqRel);
|
self.generation.fetch_add(1, Ordering::AcqRel);
|
||||||
@@ -728,6 +843,109 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const DEVICE: SecretProtection = SecretProtection::DeviceUnlocked;
|
const DEVICE: SecretProtection = SecretProtection::DeviceUnlocked;
|
||||||
|
#[test]
|
||||||
|
fn interrupted_biometric_removal_recovers_from_the_committed_setting() {
|
||||||
|
for enabled in [true, false] {
|
||||||
|
let (root, os, backend) = setup();
|
||||||
|
let protection = SecretProtection::BiometryCurrentSet;
|
||||||
|
let locator = SecretLocator::OpenPgpPassphrase {
|
||||||
|
fingerprint: "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30".into(),
|
||||||
|
};
|
||||||
|
backend
|
||||||
|
.create(&locator, protection, b"public interruption fixture")
|
||||||
|
.unwrap();
|
||||||
|
let aliases = os.aliases().unwrap();
|
||||||
|
let interrupted = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
let _ = backend.delete_transaction(
|
||||||
|
std::slice::from_ref(&locator),
|
||||||
|
protection,
|
||||||
|
&mut || panic!("simulated process interruption"),
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
assert!(interrupted.is_err());
|
||||||
|
os.state.lock().unwrap().failure = Some(AndroidKeystoreError::Invalidated);
|
||||||
|
backend.verify_key_inventory(enabled).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
backend
|
||||||
|
.read(&identity(&locator).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.is_some(),
|
||||||
|
enabled
|
||||||
|
);
|
||||||
|
let reopened = AndroidSecretBackend::open_with_biometry(
|
||||||
|
Dir::open_ambient_dir(root.path(), ambient_authority()).unwrap(),
|
||||||
|
os.clone(),
|
||||||
|
enabled,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
reopened
|
||||||
|
.read(&identity(&locator).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.is_some(),
|
||||||
|
enabled
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
os.aliases().unwrap(),
|
||||||
|
if enabled { aliases } else { Vec::new() }
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!root
|
||||||
|
.path()
|
||||||
|
.join(format!("{}.removing", identity(&locator).unwrap()))
|
||||||
|
.exists()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn biometric_deletion_rolls_back_envelopes_without_authenticating_invalidated_keys() {
|
||||||
|
let (root, os, backend) = setup();
|
||||||
|
let protection = SecretProtection::BiometryCurrentSet;
|
||||||
|
let locators = [
|
||||||
|
"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30",
|
||||||
|
"B37027B56FC406BD3F6A622B2AC03492B992D06F",
|
||||||
|
]
|
||||||
|
.map(|fingerprint| SecretLocator::OpenPgpPassphrase {
|
||||||
|
fingerprint: fingerprint.into(),
|
||||||
|
});
|
||||||
|
for locator in &locators {
|
||||||
|
backend
|
||||||
|
.create(locator, protection, b"protected test passphrase")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let aliases = os.aliases().unwrap();
|
||||||
|
os.state.lock().unwrap().failure = Some(AndroidKeystoreError::Invalidated);
|
||||||
|
assert_eq!(
|
||||||
|
backend.delete_transaction(&locators, protection, &mut || Err(
|
||||||
|
SecretStoreError::Unavailable
|
||||||
|
)),
|
||||||
|
Err(SecretStoreError::Unavailable)
|
||||||
|
);
|
||||||
|
assert_eq!(os.aliases().unwrap(), aliases);
|
||||||
|
for locator in &locators {
|
||||||
|
assert!(backend.read(&identity(locator).unwrap()).unwrap().is_some());
|
||||||
|
}
|
||||||
|
let reopened = AndroidSecretBackend::open(
|
||||||
|
Dir::open_ambient_dir(root.path(), ambient_authority()).unwrap(),
|
||||||
|
os.clone(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(os.aliases().unwrap(), aliases);
|
||||||
|
reopened
|
||||||
|
.delete_transaction(&locators, protection, &mut || Ok(()))
|
||||||
|
.unwrap();
|
||||||
|
assert!(os.aliases().unwrap().is_empty());
|
||||||
|
for locator in &locators {
|
||||||
|
assert!(
|
||||||
|
reopened
|
||||||
|
.read(&identity(locator).unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roundtrip_duplicate_replace_delete_and_no_cleartext_on_disk() {
|
fn roundtrip_duplicate_replace_delete_and_no_cleartext_on_disk() {
|
||||||
let (root, os, backend) = setup();
|
let (root, os, backend) = setup();
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
|
|||||||
};
|
};
|
||||||
let fixture = FixtureSet::load()?;
|
let fixture = FixtureSet::load()?;
|
||||||
let vault = fixture.materialize_store("basic")?;
|
let vault = fixture.materialize_store("basic")?;
|
||||||
|
let repository = Repository::open(vault.path())?;
|
||||||
|
ironstorage::git::GitRepository::init(
|
||||||
|
&repository,
|
||||||
|
ironstorage::git::GitIdentity::new("Original", "original@example.test")?,
|
||||||
|
)?;
|
||||||
let root = tempfile::tempdir()?;
|
let root = tempfile::tempdir()?;
|
||||||
let source = root.path().join("config.toml");
|
let source = root.path().join("config.toml");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
@@ -69,7 +74,22 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
|
|||||||
1,
|
1,
|
||||||
"preferences must use the injected backend"
|
"preferences must use the injected backend"
|
||||||
);
|
);
|
||||||
authentication.set_authentication_timeout(30)?;
|
let original = std::fs::read(&source)?;
|
||||||
|
for value in ["not a number", "-1", "0", "86401", "18446744073709551616"] {
|
||||||
|
assert!(
|
||||||
|
authentication
|
||||||
|
.set_authentication_timeout_text(value)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(&source)?, original);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
authentication
|
||||||
|
.set_git_identity("valid".to_owned(), "invalid\nemail".to_owned())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(std::fs::read(&source)?, original);
|
||||||
|
authentication.set_authentication_timeout_text(" 30 ")?;
|
||||||
authentication.unlock_entry(
|
authentication.unlock_entry(
|
||||||
"email/personal",
|
"email/personal",
|
||||||
Some(SecretBytes::new(
|
Some(SecretBytes::new(
|
||||||
@@ -108,6 +128,57 @@ fn mobile_context_uses_injected_os_backend_clock_and_lifecycle_without_global_co
|
|||||||
assert!(authentication.entry_editor(editor.id()).is_err());
|
assert!(authentication.entry_editor(editor.id()).is_err());
|
||||||
}
|
}
|
||||||
let baseline_locks = backend.locks();
|
let baseline_locks = backend.locks();
|
||||||
|
let identity = authentication.set_git_identity(
|
||||||
|
"Android settings author".to_owned(),
|
||||||
|
"android-settings@example.test".to_owned(),
|
||||||
|
)?;
|
||||||
|
authentication.unlock_entry(
|
||||||
|
"email/personal",
|
||||||
|
Some(SecretBytes::new(
|
||||||
|
fixture.key("alice")?.passphrase.as_bytes().to_vec(),
|
||||||
|
)),
|
||||||
|
)?;
|
||||||
|
let editor = authentication.begin_entry_editor("email/personal")?;
|
||||||
|
let id = editor.page().fields()[0].id();
|
||||||
|
authentication.discard_entry_editor(editor.id())?;
|
||||||
|
authentication.replace_entry_field(
|
||||||
|
"email/personal",
|
||||||
|
id,
|
||||||
|
"public-settings-password".to_owned(),
|
||||||
|
)?;
|
||||||
|
let git = ironstorage::git::GitRepository::open(&repository, identity)?;
|
||||||
|
let commits = git.log(Some(1))?;
|
||||||
|
assert_eq!(commits[0].author_name(), "Android settings author");
|
||||||
|
assert_eq!(commits[0].author_email(), "android-settings@example.test");
|
||||||
|
let store = SecretStore::new(
|
||||||
|
backend.clone(),
|
||||||
|
SecretCachePolicy::Disabled,
|
||||||
|
SecretProtectionPolicy::device_unlocked(),
|
||||||
|
);
|
||||||
|
store.unlock()?;
|
||||||
|
let configured = Config::load(Some(&source))?;
|
||||||
|
let (server, application) = configured.git_remotes()[0].https_credentials().unwrap();
|
||||||
|
store.store_https_git_credential(
|
||||||
|
server,
|
||||||
|
application,
|
||||||
|
"public-settings-account",
|
||||||
|
SecretBytes::new(b"public-settings-token".to_vec()),
|
||||||
|
)?;
|
||||||
|
store.lock()?;
|
||||||
|
assert_eq!(
|
||||||
|
authentication
|
||||||
|
.preferences(false, false, false)?
|
||||||
|
.application_account(),
|
||||||
|
Some("public-settings-account")
|
||||||
|
);
|
||||||
|
authentication.remove_application_token()?;
|
||||||
|
assert_eq!(
|
||||||
|
authentication
|
||||||
|
.preferences(false, false, false)?
|
||||||
|
.application_account(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
authentication.remove_application_token()?;
|
||||||
drop(authentication);
|
drop(authentication);
|
||||||
assert!(
|
assert!(
|
||||||
backend.locks() > baseline_locks,
|
backend.locks() > baseline_locks,
|
||||||
|
|||||||
@@ -228,6 +228,16 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu
|
|||||||
assert_eq!(warm_secrets.requests, 0);
|
assert_eq!(warm_secrets.requests, 0);
|
||||||
assert_eq!(warm.progress().cache_hits(), warm.progress().total());
|
assert_eq!(warm.progress().cache_hits(), warm.progress().total());
|
||||||
assert_eq!(warm_page.rows(), page.rows());
|
assert_eq!(warm_page.rows(), page.rows());
|
||||||
|
assert_eq!(service.cache_status(&cache).0 as usize, page.rows().len());
|
||||||
|
let rebuild = MobileTotpOperation::default();
|
||||||
|
let mut rebuild_secrets = CountingSecrets::all(&fixture);
|
||||||
|
let rebuilt = service.rebuild(&BTreeSet::new(), &mut rebuild_secrets, &cache, &rebuild)?;
|
||||||
|
assert_eq!(rebuilt.rows(), page.rows());
|
||||||
|
assert_eq!(rebuild.progress().cache_hits(), 0);
|
||||||
|
assert!(
|
||||||
|
rebuild_secrets.requests > 0,
|
||||||
|
"explicit rebuild must inspect ciphertext again"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
service
|
service
|
||||||
.cached_page(&BTreeSet::new(), &cache)
|
.cached_page(&BTreeSet::new(), &cache)
|
||||||
@@ -328,6 +338,8 @@ fn totp_cache_reuses_ciphertext_hashes_and_removes_deleted_entries() -> TestResu
|
|||||||
);
|
);
|
||||||
|
|
||||||
fs::write(&cache, "not a TOTP catalog")?;
|
fs::write(&cache, "not a TOTP catalog")?;
|
||||||
|
assert_eq!(service.cache_status(&cache).0, 0);
|
||||||
|
assert!(service.cache_status(&cache).1.contains("damaged"));
|
||||||
let recovery = MobileTotpOperation::default();
|
let recovery = MobileTotpOperation::default();
|
||||||
let mut recovery_secrets = CountingSecrets::all(&fixture);
|
let mut recovery_secrets = CountingSecrets::all(&fixture);
|
||||||
let recovered = service.discover(&BTreeSet::new(), &mut recovery_secrets, &cache, &recovery)?;
|
let recovered = service.discover(&BTreeSet::new(), &mut recovery_secrets, &cache, &recovery)?;
|
||||||
|
|||||||
Reference in new issue
Block a user