Repository navigation
Pin keep v0.13.3: typed import and client-cap errors, DKG recovery over a stored share #585
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
kwsantiago
wants to merge
1
commit into
main
Choose a base branch
from
pin-v0.13.3
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
209 changes: 209 additions & 0 deletions
209
app/src/androidTest/kotlin/io/privkey/keep/storage/DkgRecoveryOverStoredShareTest.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| package io.privkey.keep.storage | ||
|
|
||
| import android.content.Context | ||
| import android.security.keystore.KeyGenParameterSpec | ||
| import android.security.keystore.KeyInfo | ||
| import android.security.keystore.KeyProperties | ||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | ||
| import androidx.test.platform.app.InstrumentationRegistry | ||
| import io.privkey.keep.AccountActions | ||
| import io.privkey.keep.R | ||
| import io.privkey.keep.uniffi.KeepMobile | ||
| import io.privkey.keep.uniffi.ShareInfo | ||
| import io.privkey.keep.uniffi.ShareMetadataInfo | ||
| import kotlinx.coroutines.CompletableDeferred | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.SupervisorJob | ||
| import kotlinx.coroutines.cancel | ||
| import kotlinx.coroutines.runBlocking | ||
| import kotlinx.coroutines.withTimeout | ||
| import org.json.JSONObject | ||
| import org.junit.After | ||
| import org.junit.Assert.assertEquals | ||
| import org.junit.Assert.assertNotNull | ||
| import org.junit.Assert.assertNull | ||
| import org.junit.Assume.assumeTrue | ||
| import org.junit.Before | ||
| import org.junit.Test | ||
| import org.junit.runner.RunWith | ||
| import java.security.KeyFactory | ||
| import java.security.KeyPairGenerator | ||
| import java.security.KeyStore | ||
| import java.security.PrivateKey | ||
| import java.util.UUID | ||
|
|
||
| /** | ||
| * Recovering a pending DKG share through the app's own flow, with the stash planted the way | ||
| * a ceremony leaves it. A share already stored under the stash's group is read rather than | ||
| * written, so the recovery hands the core the share's decrypt cipher, and the active | ||
| * account and live node stay as they were. | ||
| * | ||
| * The stash secret is wrapped under the fixed production DKG alias, so, as in | ||
| * [DkgSecretStashRoundTripTest], the alias is seeded with a twin that needs no biometric, | ||
| * and the test skips on a device that holds a real, auth-gated one. | ||
| */ | ||
| @RunWith(AndroidJUnit4::class) | ||
| class DkgRecoveryOverStoredShareTest { | ||
|
|
||
| private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext | ||
| private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } | ||
| private lateinit var storage: AndroidKeystoreStorage | ||
| private lateinit var mobile: KeepMobile | ||
| private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) | ||
| private val created = mutableSetOf<String>() | ||
| private var ownsAlias = false | ||
| private var nodeDrops = 0 | ||
|
|
||
| @Before | ||
| fun setup() { | ||
| assumeTrue("refusing to run: a real auth-gated DKG key is present on this device", !productionDkgKeyPresent()) | ||
| if (keyStore.containsAlias(DKG_ALIAS)) keyStore.deleteEntry(DKG_ALIAS) | ||
| seedNonAuthTwinAlias() | ||
| ownsAlias = true | ||
| storage = AndroidKeystoreStorage(context, requireUserAuth = false, keystoreAlias = ALIAS, prefsSuffix = SUFFIX) | ||
| mobile = KeepMobile(storage) | ||
| } | ||
|
|
||
| @After | ||
| fun teardown() { | ||
| scope.cancel() | ||
| if (!ownsAlias) return | ||
| runCatching { storage.deleteShareByKey(SECRET_KEY) } | ||
| runCatching { storage.deleteShareByKey(MARKER_KEY) } | ||
| created.forEach { group -> | ||
| runCatching { mobile.deleteShareByKey(group) } | ||
| context.deleteSharedPreferences(storage.sharePrefsNameFor(group)) | ||
| } | ||
| context.deleteSharedPreferences(storage.sharePrefsNameFor(MARKER_KEY)) | ||
| context.deleteSharedPreferences(MULTI_PREFS) | ||
| mobile.destroy() | ||
| keyStore.aliases().toList().filter { it.startsWith(ALIAS) || it == DKG_ALIAS }.forEach { keyStore.deleteEntry(it) } | ||
| } | ||
|
|
||
| @Test | ||
| fun aShareAlreadyStoredIsReadNotWrittenAndTheActiveAccountStays() { | ||
| val group = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dkg-recover-test", PASSPHRASE) | ||
| val other = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dkg-recover-other", PASSPHRASE) | ||
| import(group.shares[0].exportData) | ||
| import(other.shares[0].exportData) | ||
| assertEquals(other.groupPubkey, storage.getActiveShareKey()) | ||
| stash(group.groupPubkey, group.shares[0].exportData) | ||
|
|
||
| val result = recover() | ||
|
|
||
| assertEquals(group.groupPubkey, (result as Recovered).info?.groupPubkey) | ||
| assertEquals("the active account stays", other.groupPubkey, storage.getActiveShareKey()) | ||
| assertEquals("a recovery that wrote nothing does not drop the node", 0, nodeDrops) | ||
| assertNull("the stash is cleared", mobile.pendingDkgShare()) | ||
| } | ||
|
|
||
| @Test | ||
| fun anotherShareOfTheStoredGroupIsRefusedWithItsReasonAndTheStashKept() { | ||
| val group = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dkg-recover-test", PASSPHRASE) | ||
| import(group.shares[0].exportData) | ||
| stash(group.groupPubkey, group.shares[1].exportData) | ||
|
|
||
| val result = recover() | ||
|
|
||
| assertEquals(Dismissed(context.getString(R.string.account_import_other_share_stored)), result) | ||
| assertNotNull("the stash is kept", mobile.pendingDkgShare()) | ||
| assertEquals(0, nodeDrops) | ||
| } | ||
|
|
||
| @Test | ||
| fun aNewGroupIsStoredAndBecomesTheActiveAccount() { | ||
| val other = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dkg-recover-other", PASSPHRASE) | ||
| val group = mobile.frostGenerate(2u.toUShort(), 2u.toUShort(), "dkg-recover-test", PASSPHRASE) | ||
| import(other.shares[0].exportData) | ||
| stash(group.groupPubkey, group.shares[0].exportData) | ||
| created += group.groupPubkey | ||
|
|
||
| val result = recover() | ||
|
|
||
| assertEquals(group.groupPubkey, (result as Recovered).info?.groupPubkey) | ||
| assertEquals(group.groupPubkey, storage.getActiveShareKey()) | ||
| assertEquals("a store that moved the active account drops the node", 1, nodeDrops) | ||
| assertNull(mobile.pendingDkgShare()) | ||
| } | ||
|
|
||
| private sealed interface Outcome | ||
| private data class Recovered(val info: ShareInfo?) : Outcome | ||
| private data class Dismissed(val reason: String?) : Outcome | ||
|
|
||
| private fun recover(): Outcome { | ||
| val done = CompletableDeferred<Outcome>() | ||
| val actions = AccountActions( | ||
| keepMobile = mobile, | ||
| storage = storage, | ||
| coroutineScope = scope, | ||
| appContext = context, | ||
| onBiometricRequest = { _, _, cipher, onResult -> onResult(cipher) }, | ||
| onAccountSwitched = { true }, | ||
| onAccountDeleted = {}, | ||
| onLiveNodeDropped = { nodeDrops++ }, | ||
| onStateChanged = {}, | ||
| markBunkerRevokeAhead = { { _ -> } }, | ||
| ) | ||
| actions.recoverPendingDkgShare( | ||
| title = "test", | ||
| subtitle = "test", | ||
| onResult = { done.complete(Recovered(it)) }, | ||
| onDismiss = { done.complete(Dismissed(it)) }, | ||
| ) | ||
| return runBlocking { withTimeout(60_000) { done.await() } } | ||
| } | ||
|
|
||
| private fun import(exportData: String) { | ||
| val requestId = "dkg-recover-test-${System.nanoTime()}" | ||
| storage.setPendingCipher(requestId, storage.getCipherForEncryption()) | ||
| storage.setRequestIdContext(requestId) | ||
| try { | ||
| created += mobile.importShare(exportData, PASSPHRASE, "dkg-recover-test").groupPubkey | ||
| } finally { | ||
| storage.clearRequestIdContext() | ||
| storage.clearPendingCipher(requestId) | ||
| } | ||
| } | ||
|
|
||
| // The two records a vault-protected ceremony leaves for recovery. | ||
| private fun stash(groupPubkey: String, exportData: String) { | ||
| val marker = JSONObject().put("name", "dkg-recover-test").put("group_pubkey_hex", groupPubkey).put("vault_protected", true) | ||
| storage.storeShareByKey(MARKER_KEY, marker.toString().toByteArray(), META) | ||
| val secret = JSONObject().put("share_export", exportData).put("vault_passphrase", PASSPHRASE) | ||
| storage.storeShareByKey(SECRET_KEY, secret.toString().toByteArray(), META) | ||
| assertNotNull("the planted stash reads as pending", mobile.pendingDkgShare()) | ||
| } | ||
|
|
||
| private fun productionDkgKeyPresent(): Boolean { | ||
| if (!keyStore.containsAlias(DKG_ALIAS)) return false | ||
| val key = keyStore.getKey(DKG_ALIAS, null) as? PrivateKey ?: return false | ||
| val info = runCatching { | ||
| KeyFactory.getInstance(key.algorithm, "AndroidKeyStore").getKeySpec(key, KeyInfo::class.java) as KeyInfo | ||
| }.getOrNull() ?: return false | ||
| return info.isUserAuthenticationRequired | ||
| } | ||
|
|
||
| private fun seedNonAuthTwinAlias() { | ||
| KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore").apply { | ||
| initialize( | ||
| KeyGenParameterSpec.Builder(DKG_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) | ||
| .setKeySize(2048) | ||
| .setDigests(KeyProperties.DIGEST_SHA256) | ||
| .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) | ||
| .build() | ||
| ) | ||
| }.generateKeyPair() | ||
| } | ||
|
|
||
| private companion object { | ||
| const val SUFFIX = "_dkg_recover_test" | ||
| const val ALIAS = "keep_frost_share$SUFFIX" | ||
| const val MULTI_PREFS = "keep_multi_share_prefs$SUFFIX" | ||
| const val DKG_ALIAS = "keep_dkg_secret" | ||
| const val MARKER_KEY = "__keep_dkg_pending_v1" | ||
| const val SECRET_KEY = "__keep_dkg_secret_v1" | ||
| const val PASSPHRASE = "dkg-recover-test-passphrase" | ||
| val META = ShareMetadataInfo("dkg_pending", 0u, 0u, 0u, ByteArray(0), false) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do not delete an existing DKG key during test setup.
If
productionDkgKeyPresent()cannot inspect an existing key, it returnsfalse. Line 61 then deletes the key. The pending DKG secret uses this fixed key alias and an unsuffixed preferences file, so deleting the key can make a pending share unrecoverable. Skip the test whenever the alias already exists. Seed and delete only a key that this test created. (raw.githubusercontent.com)