Implement structured Android entry viewer (#99)
This commit is contained in:
1 parent
647dc1bb4b
commit
c0ba6d43d8
29 files changed
+1768
-78
No files matched your search
+228
@@ -0,0 +1,228 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorFieldKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryEditorInput
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Actual encrypted Rust documents and Kotlin facade, with deterministic OS capabilities. */
|
||||
class NativeEntryViewerTest {
|
||||
@Test fun fieldsEditingCopyMutationsAndTotpExpiryUseTheNativeOwner() = runBlocking {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val name = "viewer-${UUID.randomUUID()}"
|
||||
val roots = listOf(context.filesDir, context.noBackupFilesDir, context.cacheDir).map {
|
||||
File(it, name).apply { mkdirs() }.canonicalPath
|
||||
}
|
||||
val session = NativePrivateSession(context, {
|
||||
PrivateDirectories(roots[0], roots[1], roots[2])
|
||||
}, MemoryKeystore())
|
||||
withContext(Dispatchers.IO) { session.initialize() }
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||
val clipboard = MemoryClipboard()
|
||||
val authentication = NativeAuthenticationRepository(session, scope, Dispatchers.IO)
|
||||
val repository =
|
||||
NativePasswordRepository(session, scope, authentication, Dispatchers.IO, clipboard)
|
||||
val password = "public-viewer-passphrase"
|
||||
suspend fun request(action: () -> Unit): PasswordState {
|
||||
withContext(Dispatchers.Main.immediate) { action() }
|
||||
return withTimeout(30000) { repository.state.first { !it.loading } }
|
||||
}
|
||||
fun fields() = requireNotNull(repository.state.value.sections).flatMap { it.fields }
|
||||
suspend fun open() {
|
||||
val navigation = request { repository.restore("", null, null) }
|
||||
val row = navigation.rows.single { it.title == "viewer" }
|
||||
request { repository.activate(row.id, navigation.revision) }
|
||||
assertNull(repository.state.value.sections)
|
||||
val page = request { repository.unlock(password) }
|
||||
assertNull(page.error)
|
||||
assertNotNull(page.sections)
|
||||
}
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val setup = session.storage().androidOnboarding(session.backend())
|
||||
val key = setup.generate("Viewer fixture", password).selectedKey
|
||||
setup.setupLocal(requireNotNull(key), password)
|
||||
}
|
||||
authentication.authenticated { auth ->
|
||||
val draft = auth.beginCreateEntry("", "viewer", password)
|
||||
val first = draft.page.fields.first()
|
||||
auth.updateEntryEditor(
|
||||
draft.id,
|
||||
listOf(MobileEntryEditorInput(first.id, first.name, "fixture-secret"))
|
||||
)
|
||||
auth.addEntryEditorField(
|
||||
draft.id,
|
||||
MobileEntryEditorFieldKind.FIELD,
|
||||
"PIN",
|
||||
"123493"
|
||||
)
|
||||
auth.addEntryEditorField(
|
||||
draft.id,
|
||||
MobileEntryEditorFieldKind.FIELD,
|
||||
"custom",
|
||||
"Unicode Ελληνικά 日本語"
|
||||
)
|
||||
auth.addEntryEditorField(
|
||||
draft.id,
|
||||
MobileEntryEditorFieldKind.FIELD,
|
||||
"custom",
|
||||
"Repeated field"
|
||||
)
|
||||
auth.addEntryEditorField(
|
||||
draft.id,
|
||||
MobileEntryEditorFieldKind.NOTE,
|
||||
null,
|
||||
"First line\nSecond line"
|
||||
)
|
||||
val page = auth.addEntryEditorField(
|
||||
draft.id,
|
||||
MobileEntryEditorFieldKind.OTP_URI,
|
||||
null,
|
||||
"otpauth://totp/Fixture:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Fixture"
|
||||
)
|
||||
auth.saveEntryEditor(
|
||||
draft.id,
|
||||
page.fields.map {
|
||||
MobileEntryEditorInput(it.id, it.name, it.value.orEmpty())
|
||||
}
|
||||
)
|
||||
auth.createDirectory("", "destination")
|
||||
auth.setAuthenticationTimeout(30u)
|
||||
}
|
||||
open()
|
||||
val sensitive = fields().filter { it.sensitive }
|
||||
assertEquals(3, sensitive.size)
|
||||
assertTrue(sensitive.all { it.value == "Hidden" && !it.revealed && !it.selectable })
|
||||
assertEquals(2, fields().count { it.label == "Custom" })
|
||||
assertTrue(fields().any { it.value == "First line\nSecond line" && it.multiline })
|
||||
assertNotNull(repository.state.value.totp)
|
||||
assertTrue(repository.state.value.totp!!.remainingSeconds in 1uL..30uL)
|
||||
val pin = sensitive.single { it.label == "PIN" }
|
||||
request { repository.reveal(pin.id) }
|
||||
assertEquals("123493", fields().single { it.id == pin.id }.value)
|
||||
request { repository.hide(pin.id) }
|
||||
assertEquals("Hidden", fields().single { it.id == pin.id }.value)
|
||||
request { repository.copy(pin.id) }
|
||||
withTimeout(10000) { while (clipboard.value == null) delay(20) }
|
||||
assertEquals("123493", clipboard.value!!.toString(Charsets.UTF_8))
|
||||
request { repository.edit(pin.id) }
|
||||
assertEquals(pin.id, repository.state.value.edit!!.id)
|
||||
request { repository.saveEdit("654321") }
|
||||
assertNull(repository.state.value.error)
|
||||
assertNull(repository.state.value.edit)
|
||||
assertEquals("Hidden", fields().single { it.id == pin.id }.value)
|
||||
request { repository.reveal(pin.id) }
|
||||
assertEquals("654321", fields().single { it.id == pin.id }.value)
|
||||
request { repository.prepareMutation(PasswordMutation.Copy) }
|
||||
val target = repository.state.value.mutation!!.destinations.single {
|
||||
it.path ==
|
||||
"destination"
|
||||
}
|
||||
request { repository.mutate(target.path, false) }
|
||||
assertNull(repository.state.value.error)
|
||||
request { repository.restore("destination", null, null) }
|
||||
assertEquals("viewer", repository.state.value.rows.single().title)
|
||||
open()
|
||||
request { repository.prepareMutation(PasswordMutation.Delete) }
|
||||
request { repository.mutate(null, false) }
|
||||
assertTrue(repository.state.value.rows.none { it.title == "viewer" })
|
||||
val copied = request { repository.restore("destination", null, null) }
|
||||
request { repository.activate(copied.rows.single().id, copied.revision) }
|
||||
request { repository.unlock(password) }
|
||||
request { repository.edit(fields().first().id) }
|
||||
request { repository.lock() }
|
||||
assertNull(repository.state.value.sections)
|
||||
assertNull(repository.state.value.edit)
|
||||
assertNull(repository.state.value.totp)
|
||||
withTimeout(10000) { while (clipboard.value != null) delay(20) }
|
||||
request { repository.unlock(password) }
|
||||
authentication.authenticated {
|
||||
it.replaceEntryField("destination/viewer", pin.id, "stale external update")
|
||||
}
|
||||
request { repository.reveal(pin.id) }
|
||||
assertNull(repository.state.value.sections)
|
||||
assertFalse(authentication.state.value.unlocked)
|
||||
val changed = request { repository.restore("destination", null, null) }
|
||||
request { repository.activate(changed.rows.single().id, changed.revision) }
|
||||
request { repository.unlock(password) }
|
||||
authentication.authenticated { it.setAuthenticationTimeout(2u) }
|
||||
withTimeout(10000) { authentication.state.first { !it.unlocked } }
|
||||
withTimeout(10000) { repository.state.first { it.sections == null } }
|
||||
assertNull(repository.state.value.totp)
|
||||
assertFalse(authentication.state.value.unlocked)
|
||||
} finally {
|
||||
withContext(Dispatchers.Main.immediate) { repository.lock() }
|
||||
scope.cancel()
|
||||
}
|
||||
}
|
||||
private class MemoryClipboard : SecretClipboard {
|
||||
@Volatile var value: ByteArray? = null
|
||||
override fun read() = value?.copyOf()
|
||||
override fun write(value: ByteArray) {
|
||||
this.value = value.copyOf()
|
||||
}
|
||||
override fun clear() {
|
||||
value = null
|
||||
}
|
||||
}
|
||||
private class MemoryKeystore : AndroidKeystore {
|
||||
private val keys = mutableSetOf<String>()
|
||||
override fun deviceState() =
|
||||
KeystoreDeviceState(android.os.Build.VERSION.SDK_INT, false, true, false, false)
|
||||
override fun createKey(specification: KeystoreKeySpecification) {
|
||||
keys.add(specification.alias)
|
||||
}
|
||||
private data class Record(val alias: String, val aad: ByteArray, val secret: ByteArray)
|
||||
private val ciphertexts = mutableMapOf<List<Byte>, Record>()
|
||||
override fun encrypt(
|
||||
alias: String,
|
||||
authorization: KeystoreAuthorization,
|
||||
aad: ByteArray,
|
||||
value: ByteArray
|
||||
): KeystoreCiphertext {
|
||||
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
|
||||
val seed = UUID.randomUUID().toString().toByteArray()
|
||||
val token = ByteArray(value.size + 16) { seed[it % seed.size] }
|
||||
ciphertexts[token.toList()] = Record(alias, aad.copyOf(), value.copyOf())
|
||||
return KeystoreCiphertext(token.take(12).toByteArray(), token)
|
||||
}
|
||||
override fun decrypt(
|
||||
alias: String,
|
||||
authorization: KeystoreAuthorization,
|
||||
aad: ByteArray,
|
||||
value: KeystoreCiphertext
|
||||
): ByteArray {
|
||||
if (alias !in keys) throw KeystoreException(KeystoreFailure.MissingKey)
|
||||
val record =
|
||||
ciphertexts[value.bytes.toList()]
|
||||
?: throw KeystoreException(KeystoreFailure.Corrupted)
|
||||
if (record.alias != alias || !record.aad.contentEquals(aad) ||
|
||||
!value.nonce.contentEquals(value.bytes.take(12).toByteArray())
|
||||
) {
|
||||
throw KeystoreException(KeystoreFailure.Corrupted)
|
||||
}
|
||||
return record.secret.copyOf()
|
||||
}
|
||||
override fun deleteKey(alias: String) {
|
||||
keys.remove(alias)
|
||||
}
|
||||
override fun aliases() = keys.toList()
|
||||
override fun cancelPending() = Unit
|
||||
}
|
||||
}
|
||||
+295
-24
@@ -1,17 +1,29 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
import android.os.SystemClock
|
||||
import de.rfc1437.ironstorage.bridge.NativeBridgeException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileAuthenticationFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboard
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardContent
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileClipboardWait
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileEntryPresentation
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileMutationAction
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobileMutationRequest
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordNavigation
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePasswordRowKind
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePlatformFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.MobilePrivateStorageFfiException
|
||||
import de.rfc1437.ironstorage.bridge.generated.copyMobileEntryClearing
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -20,7 +32,8 @@ class NativePasswordRepository(
|
||||
private val session: NativePrivateSession,
|
||||
private val scope: CoroutineScope,
|
||||
private val authentication: NativeAuthenticationRepository,
|
||||
private val io: CoroutineDispatcher
|
||||
private val io: CoroutineDispatcher,
|
||||
private val clipboard: SecretClipboard
|
||||
) : PasswordRepository {
|
||||
private val mutable = MutableStateFlow(PasswordState())
|
||||
override val state: StateFlow<PasswordState> = mutable
|
||||
@@ -29,7 +42,32 @@ class NativePasswordRepository(
|
||||
init {
|
||||
scope.launch {
|
||||
authentication.state.collect { lease ->
|
||||
if (!lease.unlocked) mutable.value = mutable.value.copy(sections = null)
|
||||
if (!lease.unlocked) mutable.value = mutable.value.cleared()
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
val current = mutable.value
|
||||
if (current.totp != null && !current.loading &&
|
||||
authentication.state.value.unlocked &&
|
||||
!authentication.state.value.busy
|
||||
) {
|
||||
content { current ->
|
||||
val detail = authentication.authenticated { auth ->
|
||||
auth.refreshEntryPresentation(
|
||||
requireNotNull(current.selected).path,
|
||||
requireNotNull(current.documentRevision),
|
||||
unixSeconds()
|
||||
).totp
|
||||
}
|
||||
current.copy(
|
||||
totp = detail?.let {
|
||||
PasswordTotp(it.code, it.account, it.remainingSeconds)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,31 +121,228 @@ class NativePasswordRepository(
|
||||
(System.currentTimeMillis() / 1000).toULong()
|
||||
)
|
||||
}
|
||||
verified.copy(
|
||||
sections = page.page.sections.map { section ->
|
||||
PasswordSection(
|
||||
section.title,
|
||||
section.fields.map { field ->
|
||||
PasswordField(
|
||||
field.id,
|
||||
field.label,
|
||||
field.value ?: field.maskedValue,
|
||||
field.diagnostic
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
verified.present(page)
|
||||
}
|
||||
}
|
||||
override fun lock() {
|
||||
sequence.incrementAndGet()
|
||||
mutable.value = mutable.value.copy(sections = null, loading = false)
|
||||
mutable.value = mutable.value.cleared().copy(loading = false)
|
||||
authentication.lock()
|
||||
}
|
||||
private fun run(action: suspend () -> PasswordState) {
|
||||
override fun hide(id: ULong) {
|
||||
sequence.incrementAndGet()
|
||||
mutable.value = mutable.value.copy(
|
||||
loading = false,
|
||||
sections = mutable.value.sections?.map { section ->
|
||||
section.copy(
|
||||
fields = section.fields.map { field ->
|
||||
if (field.id ==
|
||||
id
|
||||
) {
|
||||
field.copy(value = field.maskedValue, revealed = false)
|
||||
} else {
|
||||
field
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
override fun reveal(id: ULong) = content { current ->
|
||||
val value = authentication.authenticated {
|
||||
it.revealPresentedEntryField(
|
||||
current.entryPath(),
|
||||
id,
|
||||
requireNotNull(current.documentRevision)
|
||||
)
|
||||
}
|
||||
current.copy(
|
||||
sections = current.sections?.map { section ->
|
||||
section.copy(
|
||||
fields = section.fields.map { field ->
|
||||
if (field.id == id) field.copy(value = value, revealed = true) else field
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
override fun edit(id: ULong) = content { current ->
|
||||
val field =
|
||||
current.sections?.flatMap { it.fields }?.find { it.id == id && it.editable }
|
||||
?: return@content current
|
||||
val value = authentication.authenticated {
|
||||
it.revealPresentedEntryField(
|
||||
current.entryPath(),
|
||||
id,
|
||||
requireNotNull(current.documentRevision)
|
||||
)
|
||||
}
|
||||
current.copy(
|
||||
edit = PasswordEdit(
|
||||
id,
|
||||
field.label,
|
||||
value,
|
||||
field.multiline,
|
||||
field.sensitive
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun dismissEdit() {
|
||||
sequence.incrementAndGet()
|
||||
mutable.value = mutable.value.copy(edit = null, loading = false)
|
||||
}
|
||||
override fun saveEdit(value: String) = content { current ->
|
||||
val edit = current.edit ?: return@content current
|
||||
authentication.authenticated {
|
||||
it.replacePresentedEntryField(
|
||||
current.entryPath(),
|
||||
edit.id,
|
||||
requireNotNull(current.documentRevision),
|
||||
value
|
||||
)
|
||||
}
|
||||
val page = authentication.authenticated { it.entryPage(current.entryPath(), unixSeconds()) }
|
||||
val folder = session.storage().passwordNavigation(current.path, null, null, false)
|
||||
val refreshed = session.storage().passwordNavigation(
|
||||
current.path,
|
||||
current.selected?.id,
|
||||
folder.revision,
|
||||
false
|
||||
).toFacade()
|
||||
if (refreshed.selected == null) refreshed else refreshed.present(page)
|
||||
}
|
||||
override fun copy(id: ULong) = copyValue { auth, current ->
|
||||
auth.copyPresentedEntryField(
|
||||
current.entryPath(),
|
||||
id,
|
||||
requireNotNull(current.documentRevision)
|
||||
)
|
||||
}
|
||||
override fun copyTotp() = copyValue { auth, current ->
|
||||
auth.copyPresentedTotpCode(
|
||||
current.entryPath(),
|
||||
requireNotNull(current.documentRevision),
|
||||
unixSeconds()
|
||||
)
|
||||
}
|
||||
private fun copyValue(
|
||||
get: (de.rfc1437.ironstorage.bridge.generated.MobileAuthentication, PasswordState) ->
|
||||
de.rfc1437.ironstorage.bridge.generated.MobileEntryCopy
|
||||
) = content { current ->
|
||||
val copy = authentication.authenticated { get(it, current) }
|
||||
val ticket = sequence.get()
|
||||
scope.launch(io) {
|
||||
try {
|
||||
copyMobileEntryClearing(
|
||||
copy,
|
||||
object : MobileClipboard {
|
||||
override fun read(): MobileClipboardContent =
|
||||
runBlocking(Dispatchers.Main.immediate) {
|
||||
clipboard.read()?.let { MobileClipboardContent.Text(it) }
|
||||
?: MobileClipboardContent.EmptyOrNonText
|
||||
}
|
||||
override fun write(value: ByteArray) =
|
||||
runBlocking(Dispatchers.Main.immediate) {
|
||||
// A lock may have happened after Rust returned the copy value.
|
||||
if (!authentication.state.value.unlocked) {
|
||||
throw MobilePlatformFfiException.Cancelled()
|
||||
}
|
||||
clipboard.write(value)
|
||||
if (ticket == sequence.get()) {
|
||||
mutable.value = mutable.value.copy(
|
||||
notice = "Copied. The clipboard will be " +
|
||||
"cleared automatically."
|
||||
)
|
||||
}
|
||||
}
|
||||
override fun clear() = runBlocking(Dispatchers.Main.immediate) {
|
||||
clipboard.clear()
|
||||
}
|
||||
override fun waitForTimeout(milliseconds: ULong): MobileClipboardWait {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
while ((SystemClock.elapsedRealtime() - start).toULong() <
|
||||
milliseconds
|
||||
) {
|
||||
if (!authentication.state.value.unlocked) {
|
||||
return MobileClipboardWait.CANCELLED
|
||||
}
|
||||
Thread.sleep(50)
|
||||
}
|
||||
return MobileClipboardWait.ELAPSED
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (_: MobilePlatformFfiException.Cancelled) {
|
||||
// Rust cleans up before returning cancellation.
|
||||
} catch (_: MobilePlatformFfiException) {
|
||||
withContext(Dispatchers.Main.immediate) {
|
||||
if (ticket == sequence.get()) {
|
||||
mutable.value =
|
||||
mutable.value.copy(error = "Copy is unavailable.", notice = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
current
|
||||
}
|
||||
override fun prepareMutation(action: PasswordMutation) = content { current ->
|
||||
val plan = authentication.authenticated {
|
||||
it.preparePresentedEntryMutation(
|
||||
current.entryPath(),
|
||||
requireNotNull(current.documentRevision),
|
||||
action.native()
|
||||
)
|
||||
}
|
||||
current.copy(
|
||||
mutation = PasswordMutationPlan(
|
||||
action,
|
||||
plan.source,
|
||||
plan.revision,
|
||||
plan.destinations.map {
|
||||
PasswordDestination(it.path, it.title, it.detail, it.requiresOverwrite)
|
||||
},
|
||||
plan.hasOpenEditor
|
||||
)
|
||||
)
|
||||
}
|
||||
override fun dismissMutation() {
|
||||
sequence.incrementAndGet()
|
||||
mutable.value = mutable.value.copy(mutation = null, loading = false)
|
||||
}
|
||||
override fun mutate(destination: String?, overwrite: Boolean) = content { current ->
|
||||
val plan = current.mutation ?: return@content current
|
||||
authentication.authenticated {
|
||||
it.performEntryMutation(
|
||||
MobileMutationRequest(
|
||||
plan.action.native(),
|
||||
plan.source,
|
||||
plan.revision,
|
||||
destination,
|
||||
true,
|
||||
overwrite,
|
||||
plan.discardEditor
|
||||
)
|
||||
)
|
||||
}
|
||||
session.storage().passwordNavigation(current.path, null, null, false).toFacade()
|
||||
}
|
||||
private fun content(action: suspend (PasswordState) -> PasswordState) {
|
||||
val current = mutable.value
|
||||
if (current.sections == null || current.selected == null ||
|
||||
current.documentRevision == null || !authentication.state.value.unlocked ||
|
||||
current.loading
|
||||
) {
|
||||
return
|
||||
}
|
||||
run(false) { action(current) }
|
||||
}
|
||||
private fun run(clear: Boolean = true, action: suspend () -> PasswordState) {
|
||||
val ticket = sequence.incrementAndGet()
|
||||
mutable.value = mutable.value.copy(loading = true, error = null, sections = null)
|
||||
mutable.value =
|
||||
(if (clear) mutable.value.cleared() else mutable.value).copy(
|
||||
loading = true,
|
||||
error = null
|
||||
)
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
if (sequence.get() != ticket) return@withLock
|
||||
@@ -130,10 +365,9 @@ class NativePasswordRepository(
|
||||
)
|
||||
}
|
||||
if (sequence.get() == ticket) {
|
||||
mutable.value = result.copy(
|
||||
loading = false,
|
||||
sections = result.sections.takeIf { authentication.state.value.unlocked }
|
||||
)
|
||||
mutable.value =
|
||||
(if (authentication.state.value.unlocked) result else result.cleared())
|
||||
.copy(loading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,3 +398,40 @@ private fun MobilePasswordNavigation.toFacade(): PasswordState {
|
||||
stale = stale
|
||||
)
|
||||
}
|
||||
|
||||
private fun unixSeconds() = (System.currentTimeMillis() / 1000).toULong()
|
||||
private fun PasswordState.cleared() = copy(
|
||||
sections = null,
|
||||
totp = null,
|
||||
documentRevision = null,
|
||||
edit = null,
|
||||
mutation = null,
|
||||
notice = null
|
||||
)
|
||||
private fun PasswordState.present(value: MobileEntryPresentation) = copy(
|
||||
documentRevision = value.page.revision,
|
||||
sections = value.page.sections.map { section ->
|
||||
PasswordSection(
|
||||
section.title,
|
||||
section.fields.map { field ->
|
||||
PasswordField(
|
||||
field.id, field.label, field.value ?: field.maskedValue, field.diagnostic,
|
||||
field.sensitive, field.selectable, field.editable,
|
||||
field.multiline, field.detail,
|
||||
maskedValue = field.maskedValue
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
totp = value.totp?.let { PasswordTotp(it.code, it.account, it.remainingSeconds) },
|
||||
notice = value.cacheNotice,
|
||||
edit = null,
|
||||
mutation = null
|
||||
)
|
||||
private fun PasswordMutation.native() = when (this) {
|
||||
PasswordMutation.Move -> MobileMutationAction.MOVE
|
||||
PasswordMutation.Copy -> MobileMutationAction.COPY
|
||||
PasswordMutation.Delete -> MobileMutationAction.DELETE
|
||||
}
|
||||
|
||||
private fun PasswordState.entryPath() = requireNotNull(selected).path
|
||||
@@ -16,9 +16,44 @@ data class PasswordField(
|
||||
val id: ULong,
|
||||
val label: String,
|
||||
val value: String,
|
||||
val diagnostic: String?
|
||||
)
|
||||
val diagnostic: String?,
|
||||
val sensitive: Boolean = false,
|
||||
val selectable: Boolean = false,
|
||||
val editable: Boolean = false,
|
||||
val multiline: Boolean = false,
|
||||
val detail: String? = null,
|
||||
val revealed: Boolean = false,
|
||||
val maskedValue: String = value
|
||||
) {
|
||||
override fun toString() = "PasswordField(id=$id, [REDACTED])"
|
||||
}
|
||||
data class PasswordSection(val title: String, val fields: List<PasswordField>)
|
||||
data class PasswordTotp(val code: String, val account: String, val remainingSeconds: ULong) {
|
||||
override fun toString() = "PasswordTotp([REDACTED])"
|
||||
}
|
||||
data class PasswordEdit(
|
||||
val id: ULong,
|
||||
val label: String,
|
||||
val value: String,
|
||||
val multiline: Boolean,
|
||||
val sensitive: Boolean
|
||||
) {
|
||||
override fun toString() = "PasswordEdit(id=$id, [REDACTED])"
|
||||
}
|
||||
enum class PasswordMutation { Move, Copy, Delete }
|
||||
data class PasswordDestination(
|
||||
val path: String,
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val overwrite: Boolean
|
||||
)
|
||||
data class PasswordMutationPlan(
|
||||
val action: PasswordMutation,
|
||||
val source: String,
|
||||
val revision: String,
|
||||
val destinations: List<PasswordDestination>,
|
||||
val discardEditor: Boolean
|
||||
)
|
||||
data class PasswordState(
|
||||
val path: String = "",
|
||||
val title: String = "Passwords",
|
||||
@@ -29,7 +64,12 @@ data class PasswordState(
|
||||
val loading: Boolean = false,
|
||||
val stale: Boolean = false,
|
||||
val error: String? = null,
|
||||
val sections: List<PasswordSection>? = null
|
||||
val sections: List<PasswordSection>? = null,
|
||||
val totp: PasswordTotp? = null,
|
||||
val documentRevision: String? = null,
|
||||
val notice: String? = null,
|
||||
val edit: PasswordEdit? = null,
|
||||
val mutation: PasswordMutationPlan? = null
|
||||
)
|
||||
interface PasswordRepository {
|
||||
val state: StateFlow<PasswordState>
|
||||
@@ -40,4 +80,14 @@ interface PasswordRepository {
|
||||
fun back()
|
||||
fun unlock(passphrase: String)
|
||||
fun lock()
|
||||
fun reveal(id: ULong)
|
||||
fun hide(id: ULong)
|
||||
fun copy(id: ULong)
|
||||
fun copyTotp()
|
||||
fun edit(id: ULong)
|
||||
fun dismissEdit()
|
||||
fun saveEdit(value: String)
|
||||
fun prepareMutation(action: PasswordMutation)
|
||||
fun dismissMutation()
|
||||
fun mutate(destination: String?, overwrite: Boolean)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.rfc1437.ironstorage.core
|
||||
|
||||
/** OS clipboard capability; Rust owns timeout, cancellation cleanup and content comparison. */
|
||||
interface SecretClipboard {
|
||||
fun read(): ByteArray?
|
||||
fun write(value: ByteArray)
|
||||
fun clear()
|
||||
}
|
||||
Reference in new issue
Block a user