Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
package io.privkey.keep.nip46

import androidx.test.ext.junit.runners.AndroidJUnit4
import io.privkey.keep.uniffi.nip46ApprovalTimeoutSecs
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/**
* The approval wait has to end before the core's own backstop on the same wait. If it
* did not, the core could deny a request at its deadline while the user, a moment later,
* saw their approval accepted.
*/
@RunWith(AndroidJUnit4::class)
class ApprovalTimeoutTest {

@Test
fun theWaitEndsWellBeforeTheCoreBackstop() {
val coreMs = nip46ApprovalTimeoutSecs().toLong() * 1000L
assertTrue(
"the margin is what lets this side decide first",
BunkerService.APPROVAL_TIMEOUT_MS <= CORE_APPROVAL_TIMEOUT_MS - MIN_MARGIN_MS
BunkerService.APPROVAL_TIMEOUT_MS <= coreMs - MIN_MARGIN_MS
)
}

private companion object {
// APPROVAL_TIMEOUT in the core's nip46.rs, which is not exported.
const val CORE_APPROVAL_TIMEOUT_MS = 60_000L
const val MIN_MARGIN_MS = 5_000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,8 @@ class BunkerRevokeE2ETest {
BunkerService.start(context)
val url2 = awaitRunning(previous = null)
assertRotated(url1, url2)
val swept = System.currentTimeMillis()
assertRefused("the start must revoke the restored grant of a revoked client", settled("sign", c1.secret, url2, "1", "15"), c1.pubkey, swept)
val restarted = System.currentTimeMillis()
assertRefused("a grant revoked while the bunker was stopped must not come back on a start", settled("sign", c1.secret, url2, "1", "15"), c1.pubkey, restarted)
assertDead(url1, url2)

// Revoke from the Bunker screen while the bunker is running.
Expand Down Expand Up @@ -342,9 +342,9 @@ class BunkerRevokeE2ETest {
}

/**
* Leaves no test client a way in: a fresh bunker URL, every approved test client
* denylisted so the start sweep drops any grant it kept, and the request files (which
* hold client secrets and URLs) deleted. Each step runs whatever happened before it.
* Leaves no test client a way in: a fresh bunker key, under which the core restores no
* grant made before it, every approved test client denylisted, and the request files
* (which hold client secrets and URLs) deleted. Each step runs whatever happened before it.
*/
private fun cleanUp(mobile: KeepMobile, storage: AndroidKeystoreStorage, saved: SavedState): List<String> {
val failures = mutableListOf<String>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,46 +184,37 @@ class RevokeAllMarkTest {
}

@Test
fun aStartThatRevokedCleanlyClearsTheMark() {
fun aStartThatRevokedClearsTheMark() {
val mark = RevokeAllMark(prefs)
mark.set()
var revoked = false
mark.revokeIfSet(revoke = { revoked = true }, serving = { true }, faultsSoFar = { 0L })
mark.revokeIfSet { revoked = true }
assertTrue(revoked)
assertFalse(mark.isSet())
assertFalse(RevokeAllMark(prefs).isSet())
}

@Test
fun aStorageFaultDuringTheRevokeKeepsTheMark() {
fun aRevokeThatFailsKeepsTheMark() {
val mark = RevokeAllMark(prefs)
mark.set()
var faults = 0L
mark.revokeIfSet(revoke = { faults++ }, serving = { true }, faultsSoFar = { faults })
assertThrows(IllegalStateException::class.java) { mark.revokeIfSet { throw IllegalStateException("the grants were not saved") } }
assertTrue(mark.isSet())
assertTrue(RevokeAllMark(prefs).isSet())
}

@Test
fun aStartThatCannotCountFaultsKeepsTheMark() {
fun aStartDoesNotClearAMarkSetWhileItRevoked() {
val mark = RevokeAllMark(prefs)
mark.set()
mark.revokeIfSet(revoke = {}, serving = { true }, faultsSoFar = { null })
assertTrue(mark.isSet())
}

@Test
fun aStartThatStoppedServingKeepsTheMarkAfterItsRevoke() {
val mark = RevokeAllMark(prefs)
mark.set()
mark.revokeIfSet(revoke = {}, serving = { false }, faultsSoFar = { 0L })
mark.revokeIfSet { mark.set() }
assertTrue(mark.isSet())
}

@Test
fun aStartWithNoMarkRevokesNothing() {
var revoked = false
RevokeAllMark(prefs).revokeIfSet(revoke = { revoked = true }, serving = { true }, faultsSoFar = { 0L })
RevokeAllMark(prefs).revokeIfSet { revoked = true }
assertFalse(revoked)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.util.Base64
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.privkey.keep.uniffi.KeepMobileException
import io.privkey.keep.uniffi.StorageRead
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
Expand Down Expand Up @@ -249,6 +250,53 @@ class InfallibleStorageReadTest {
)
}

@Test
fun the_core_read_returns_the_stored_pointer_as_it_is() {
// Even one naming a share that will not read, which getActiveShareKey keeps too,
// and one naming a share that is no longer in the registry, which it would not.
seedTwoSharesWithTheFirstUnreadable(activeKey = GROUP_HEX)
assertEquals(StorageRead.Found(GROUP_HEX), storage().readActiveShareKey())

KeystoreEncryptedPrefs.create(context, MULTI_PREFS + SUFFIX).edit()
.putStringSet("all_share_keys", setOf(SECOND_GROUP_HEX))
.commit()
assertEquals(StorageRead.Found(GROUP_HEX), storage().readActiveShareKey())
}

@Test
fun the_core_read_infers_and_writes_nothing_when_no_pointer_is_stored() {
// getActiveShareKey answers this with the readable share; the core's read must
// report the absence, since a caller deciding whether to set the pointer acts on it.
seedTwoSharesWithTheFirstUnreadable(activeKey = null)
assertEquals(SECOND_GROUP_HEX, storage().getActiveShareKey())

assertEquals(StorageRead.Absent, storage().readActiveShareKey())
assertNull(
"the read must not store a pointer",
KeystoreEncryptedPrefs.create(context, MULTI_PREFS + SUFFIX).getString("active_share_key", null)
)
}

@Test
fun an_unreadable_pointer_is_unavailable_to_the_core_rather_than_absent() {
seedReadableStore()
KeystoreEncryptedPrefs.create(context, MULTI_PREFS + SUFFIX).edit().remove("active_share_key").commit()
val stored = writeAndFindStoredName(context, MULTI_PREFS + SUFFIX) {
KeystoreEncryptedPrefs.create(context, MULTI_PREFS + SUFFIX).edit().putString("active_share_key", GROUP_HEX).commit()
}
corruptStoredValue(context, MULTI_PREFS + SUFFIX, stored)

assertEquals(StorageRead.Unavailable, storage().readActiveShareKey())
}

@Test
fun a_pointer_lookup_that_cannot_complete_is_unavailable_to_the_core() {
seedReadableStore()
breakNameResolution(MULTI_PREFS + SUFFIX)

assertEquals(StorageRead.Unavailable, storage().readActiveShareKey())
}

@Test
fun an_unreadable_active_share_does_not_report_an_empty_store() {
// The same fault reached through hasShare, which answers a different
Expand Down

This file was deleted.

9 changes: 4 additions & 5 deletions app/src/main/kotlin/io/privkey/keep/AccountActions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ internal class AccountActions(
/**
* Runs [store], a core call that stores a share and makes it the active one, on the IO
* dispatcher, and treats it as an account switch when it replaced another account.
* Without the switch cleanup the previous account's grants would apply to the new key
* and a running bunker would keep serving the previous one. Compared rather than
* Without the switch cleanup the previous account's grants and bunker clients would
* carry over to the new key. Compared rather than
* assumed, so a call that switched and then failed is still cleaned up, while a
* failed attempt that changed nothing, or a first account, wipes nothing. A pointer or
* share list that cannot be read counts as a change. A store that succeeded also
Expand Down Expand Up @@ -268,9 +268,8 @@ internal class AccountActions(
// share it resolves when none is set, so it may be this one.
val wasActive = activeAccountKey == null || account.groupPubkeyHex == activeAccountKey
if (wasActive) {
// Before the delete, not after the follow-up switch prompt. The core's
// teardown leaves a running bunker serving with the deleted share, and
// this account's grants would otherwise stand until that prompt is
// Before the delete, not after the follow-up switch prompt: this account's
// grants and bunker clients would otherwise stand until that prompt is
// answered, which it may never be if the activity goes away first. Stops
// the delete when the bunker cannot be reset: the next account would
// otherwise take over its clients, with one left even without a switch.
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,7 @@ class KeepMobileApp : Application() {
mobile.initialize(relays)
}
liveNode.built(startedAt)
io.privkey.keep.nip46.BunkerService.startAfterNodeBuilt(this, mobile)
if (BuildConfig.DEBUG) Log.d(TAG, "Initialize completed, peers: ${mobile.getPeers().size}")
}

Expand Down Expand Up @@ -696,6 +697,7 @@ class KeepMobileApp : Application() {
*/
fun onLiveNodeDropped() {
liveNode.dropped()
io.privkey.keep.nip46.BunkerService.restartAfterNodeDrop()
mainHandler.post {
// Not over a node an initialize has built since this drop.
if (!liveNode.isLive()) {
Expand Down
Loading