Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e5584d4
feat(platform-wallet): let hosts ask whether keys may be disabled und…
lklimek Oct 29, 2026
5b6c8f7
feat(kotlin-sdk): bridge the DIP-11 disable check over JNI
lklimek Oct 29, 2026
bfb5871
feat(swift-sdk): ask Rust whether identity keys may be disabled
lklimek Oct 29, 2026
9c77ce9
refactor(swift-example-app): take the DIP-11 disable verdict from Rus…
lklimek Oct 29, 2026
0d57640
feat(kotlin-sdk): ask Rust whether identity keys may be disabled
lklimek Oct 29, 2026
baa70df
refactor(kotlin-example-app): take the DIP-11 disable verdict from Ru…
lklimek Oct 29, 2026
1e1587f
docs(platform-wallet): state where the clocked disable check and the …
lklimek Oct 29, 2026
c68c661
feat(platform-wallet): refuse disabling the last enabled transfer key…
lklimek Oct 29, 2026
efdb2dd
feat(kotlin-sdk): carry the refusal kind and purpose through checkKey…
lklimek Oct 29, 2026
b98b803
feat(swift-sdk): report which wallet rule refuses a key disable
lklimek Oct 29, 2026
5e79776
refactor(swift-example-app): drop the local transfer-key guard from K…
lklimek Oct 29, 2026
061f1cc
feat(kotlin-sdk): report which wallet rule refuses a key disable
lklimek Oct 29, 2026
50bf8f7
refactor(kotlin-example-app): drop the local transfer-key guard from …
lklimek Oct 29, 2026
75ebbaa
refactor: judge key disables without a clock so the gate and the upda…
lklimek Oct 29, 2026
2346e3c
fix(swift-example-app): unwrap the optional wallet id before the wall…
lklimek Oct 29, 2026
76a21f8
Merge commit '1001ddaae9' into fix/key-disable-gate-from-rust
lklimek Oct 30, 2026
18cfb77
fix(platform-wallet): count only TRANSFER keys able to sign in the la…
lklimek Oct 30, 2026
1141b70
fix(unified-sdk-jni): throw when the key ids of a disable cannot be read
lklimek Oct 30, 2026
c8d4d43
fix(platform-wallet-ffi): refuse a null key list with a nonzero count
lklimek Oct 30, 2026
c6cb821
fix(kotlin-sdk): report unknown key-disable verdict values as a typed…
lklimek Oct 30, 2026
36fb5bb
fix(swift-example-app): re-ask the disable verdict once the wallet loads
lklimek Oct 30, 2026
279bda7
fix(swift-sdk): refuse a malformed identity id and drop stale disable…
lklimek Oct 30, 2026
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,23 +1,24 @@
package org.dashfoundation.example.services

import kotlinx.coroutines.CancellationException
import org.dashfoundation.dashsdk.identity.IdentityUpdates
import org.dashfoundation.dashsdk.identity.KeyDisableRefusal
import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity

/**
* Client-side safety gate for the Disable Key action — 1:1 port of
* `KeyDisableGate.swift`.
*
* A pre-flight UI guard, not protocol logic. It mixes two kinds of refusal:
* - **Master key (consensus):** disabling a lone master key is rejected by
* consensus (`validate_master_key_uniqueness_v0`); refusing locally just
* avoids spending fees on a transition drive-abci will reject.
* - **Last auth / last transfer (client-side self-brick guards):** not
* protocol invariants — drive-abci would accept them — but disabling the
* last enabled authentication or transfer key would strand the identity,
* so we refuse locally.
* A pre-flight UI guard that decides nothing itself. Which keys may be
* disabled is the wallet's call (`IdentityUpdates.checkKeysCanBeDisabled`:
* the last valid authentication key of a security level under DIP-11, and
* the last enabled transfer key), judged in Rust and handed in as a
* [RuleVerdict]. The wallet's identity-update entry points
* refuse the same updates, so the gate only spares the user a doomed attempt
* and turns the structured refusal into copy.
*
* Operates on Room [PublicKeyEntity] rows (the string-valued `purpose` /
* `securityLevel` and nullable `disabledAt` the persister writes), so the
* comparisons match the DPP enum names verbatim.
* Operates on Room [PublicKeyEntity] rows (the nullable `disabledAt` the
* persister writes).
*/
object KeyDisableGate {

Expand All @@ -33,42 +34,77 @@ object KeyDisableGate {
data class Forbidden(val reason: String) : Evaluation
}

/** Rust's verdict for disabling the target key alone. ← Swift `RuleVerdict`. */
sealed interface RuleVerdict {
/** The wallet allows disabling the key. */
data object Allowed : RuleVerdict

/** The wallet refuses it, for the reason [refusal] names. */
data class Refused(val refusal: KeyDisableRefusal) : RuleVerdict

/**
* No verdict: the wallet is not loaded, or the check failed or is
* still running. [reason] is user-facing copy.
*/
data class Unavailable(val reason: String) : RuleVerdict
}

/**
* Evaluate the disable gate for [target] within [allKeys] (the
* identity's current public keys). [target] must be one of them.
* Ask Rust whether the wallet lets [keyId] of [identityId] be disabled.
* Without a loaded wallet, or when the check fails, there is no verdict
* and the gate forbids the action. ← Swift
* `ruleVerdict(wallet:identityId:keyId:)`.
*/
fun evaluate(target: PublicKeyEntity, allKeys: List<PublicKeyEntity>): Evaluation {
if (target.disabledAt != null) return Evaluation.AlreadyDisabled

if (target.securityLevel.equals("MASTER", ignoreCase = true)) {
return Evaluation.Forbidden(
"Master keys can't be disabled here — a master-key disable is rejected by " +
"consensus. Master-key rotation is out of scope.",
suspend fun ruleVerdict(
identityUpdates: IdentityUpdates?,
walletHandle: Long?,
identityId: ByteArray,
keyId: Int,
): RuleVerdict {
if (identityUpdates == null || walletHandle == null) {
return RuleVerdict.Unavailable(
"Identity not attached to a loaded wallet — cannot check or sign the disable " +
"transition.",
)
}

if (target.purpose.equals("AUTHENTICATION", ignoreCase = true) &&
enabledCount("AUTHENTICATION", allKeys) <= 1
) {
return Evaluation.Forbidden(
"This is the only enabled authentication key. Disabling it would leave the " +
"identity unable to sign — add another authentication key first (Add Key).",
return try {
identityUpdates.checkKeysCanBeDisabled(walletHandle, identityId, listOf(keyId))
?.let { RuleVerdict.Refused(it) }
?: RuleVerdict.Allowed
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
RuleVerdict.Unavailable(
e.message ?: "Could not check whether this key may be disabled.",
)
}
}

if (target.purpose.equals("TRANSFER", ignoreCase = true) &&
enabledCount("TRANSFER", allKeys) <= 1
) {
return Evaluation.Forbidden(
"This is the only enabled transfer key. Disabling it would break credit " +
"withdrawals — add another transfer key first (Add Key).",
)
}
/**
* Evaluate the disable gate for [target], given Rust's [ruleVerdict] for
* disabling it alone.
*/
fun evaluate(target: PublicKeyEntity, ruleVerdict: RuleVerdict): Evaluation {
if (target.disabledAt != null) return Evaluation.AlreadyDisabled

return Evaluation.Allowed
return when (ruleVerdict) {
RuleVerdict.Allowed -> Evaluation.Allowed
is RuleVerdict.Unavailable -> Evaluation.Forbidden(ruleVerdict.reason)
is RuleVerdict.Refused -> Evaluation.Forbidden(reason(ruleVerdict.refusal))
}
}

/** Count of enabled (non-disabled) keys with the given [purpose]. */
private fun enabledCount(purpose: String, keys: List<PublicKeyEntity>): Int =
keys.count { it.purpose.equals(purpose, ignoreCase = true) && it.disabledAt == null }
/** User-facing copy for a [refusal]. */
private fun reason(refusal: KeyDisableRefusal): String = when (refusal) {
is KeyDisableRefusal.LastAuthenticationKey ->
"No other valid authentication key at the ${refusal.securityLevel.name} level " +
"would remain. Disabling this key would leave the identity unable to sign " +
"what needs that level — add another key first (Add Key)."
is KeyDisableRefusal.LastEnabledKey -> {
val purpose = refusal.purpose.name.lowercase()
"No other enabled $purpose key able to sign would remain. Disabling this key " +
"would break credit withdrawals and transfers — add another $purpose key " +
"first (Add Key)."
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
Expand All @@ -34,7 +35,6 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import kotlinx.coroutines.launch
import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity
import org.dashfoundation.dashsdk.security.BiometricGate
import org.dashfoundation.example.di.LocalAppContainer
import org.dashfoundation.example.services.IdentityKeys
Expand All @@ -52,8 +52,8 @@ import org.dashfoundation.example.util.toHex
* metadata + public key bytes, a biometric-gated private-key reveal (read
* from [org.dashfoundation.dashsdk.security.WalletStorage]), and the Key
* Status section with the gated destructive Disable Key action: the
* [KeyDisableGate] pre-flight (master / last-auth / last-transfer refusals)
* in front of `PlatformWalletManager.identityUpdates.disableKeys` — the
* [KeyDisableGate] pre-flight (the wallet's verdict from Rust: DIP-11 and the
* last transfer key) in front of `PlatformWalletManager.identityUpdates.disableKeys` — the
* same `platform_wallet_update_identity_with_signer` call Swift's
* `KeyDetailView.disableKey()` makes.
*/
Expand Down Expand Up @@ -90,6 +90,22 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon
var showDisableConfirm by remember { mutableStateOf(false) }
var isDisabling by remember { mutableStateOf(false) }

// The wallet's verdict for disabling this key, re-asked whenever the key
// set or the wallet changes.
val ruleVerdict by produceState<KeyDisableGate.RuleVerdict>(
initialValue = KeyDisableGate.RuleVerdict.Unavailable(CHECKING_DISABLE_RULE),
allKeys,
wallet,
manager,
) {
value = KeyDisableGate.ruleVerdict(
manager?.identityUpdates,
wallet?.handle,
idBytes,
keyId,
)
}

LaunchedEffect(key?.publicKeyData) {
val row = key
hasPrivate = row != null &&
Expand Down Expand Up @@ -191,8 +207,8 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon

FormSection(title = "Key Status") {
when (val evaluation = KeyDisableGate.evaluate(
target = k.normalizedForGate(),
allKeys = allKeys.map { it.normalizedForGate() },
target = k,
ruleVerdict = ruleVerdict,
)) {
KeyDisableGate.Evaluation.AlreadyDisabled -> {
LabeledContent("Disabled", "Permanent")
Expand Down Expand Up @@ -253,23 +269,30 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon
TextButton(
enabled = !isDisabling,
onClick = {
// Re-check the gate at submit time — the key set could
// have changed since the view rendered. Never broadcast
// a doomed transition (← Swift disableKey()).
val target = key ?: return@TextButton
val gate = KeyDisableGate.evaluate(
target.normalizedForGate(),
allKeys.map { it.normalizedForGate() },
)
if (gate != KeyDisableGate.Evaluation.Allowed) {
showDisableConfirm = false
error = (gate as? KeyDisableGate.Evaluation.Forbidden)?.reason
return@TextButton
}
val w = wallet ?: return@TextButton
val mgr = manager ?: return@TextButton
isDisabling = true
scope.launch {
// Re-check the gate at submit time, with a fresh
// verdict — the key set could have changed since
// the view rendered. Never broadcast a doomed
// transition (← Swift disableKey()).
val gate = KeyDisableGate.evaluate(
target,
KeyDisableGate.ruleVerdict(
mgr.identityUpdates,
w.handle,
idBytes,
keyId,
),
)
if (gate != KeyDisableGate.Evaluation.Allowed) {
showDisableConfirm = false
error = (gate as? KeyDisableGate.Evaluation.Forbidden)?.reason
isDisabling = false
return@launch
}
try {
mgr.identityUpdates.disableKeys(
walletHandle = w.handle,
Expand Down Expand Up @@ -301,11 +324,4 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon
ErrorAlertDialog(message = error, onDismiss = { error = null })
}

/**
* [KeyDisableGate] compares DPP enum *names*; Room rows may store the
* stringified rawValue. Normalize before evaluating.
*/
private fun PublicKeyEntity.normalizedForGate(): PublicKeyEntity = copy(
purpose = IdentityKeys.purposeName(purpose),
securityLevel = IdentityKeys.securityLevelName(securityLevel),
)
private const val CHECKING_DISABLE_RULE = "Checking whether this key may be disabled…"
2 changes: 1 addition & 1 deletion packages/kotlin-sdk/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Status legend:
| IdentitiesView.swift | ui/identity/IdentitiesHomeScreen.kt · `IdentitiesHome` | ported |
| IdentityDetailView.swift | ui/identity/IdentityDetailScreen.kt · `IdentityDetail` | partial — Android reaches the compiled shared one-fetch `platform_wallet_sync_contested_dpns_names` + full cached `managed_identity_get_contested_dpns_names` JNI path, removes the eight-label probe cap, preserves loading/error/unavailable state with retry, and frees cached arrays through an RAII guard. Android identity persistence still drops contested snapshots on restore with no schema slot before reserved v8 invitations; allocation-failure/device and restart gates remain |
| IdentityKeyAddition.swift | services/IdentityKeyAdditionFlow.kt | ported — derive → Keystore-persist → `IdentityPubkey` flow with the real keypair deriver injected (`PlatformWalletManager.deriveIdentityKeyPair`); slot assignment, Drive-combination validation, scalar scrubbing |
| KeyDetailView.swift | ui/identity/KeyDetailScreen.kt · `KeyDetail` | ported — incl. the Key Status section: `KeyDisableGate`-guarded destructive Disable Key with confirm dialog, submitting through the bridged `IdentityUpdates.disableKeys` (`platform_wallet_update_identity_with_signer`) |
| KeyDetailView.swift | ui/identity/KeyDetailScreen.kt · `KeyDetail` | ported — incl. the Key Status section: `KeyDisableGate`-guarded destructive Disable Key with confirm dialog (the wallet's verdict from Rust via `IdentityUpdates.checkKeysCanBeDisabled` → `platform_wallet_check_keys_can_be_disabled`: DIP-11 and the last transfer key), submitting through the bridged `IdentityUpdates.disableKeys` (`platform_wallet_update_identity_with_signer`) |
| KeychainExplorerView.swift | ui/diagnostics/KeystoreExplorerScreen.kt · `KeystoreExplorer` | ported (adapted: WalletStorage entries masked + AndroidKeyStore aliases; adds biometric-gated mnemonic reveal) |
| KeysListView.swift | ui/identity/KeysListScreen.kt · `KeysList` | ported — toolbar Add action opens AddIdentityKeyScreen (← the AddIdentityKeyView sheet) |
| LoadIdentityView.swift | ui/identity/LoadIdentityScreen.kt · `LoadIdentity` | ported |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,25 @@ internal object TransactionsNative {
signerHandle: Long,
)

/**
* Whether the wallet lets an identity update disable [keyIds] of
* [identityId] — no security level loses its last valid authentication
* key (DIP-11) and an enabled TRANSFER key able to sign remains — with the same
* policy the identity update refuses with. Bridges
* `platform_wallet_check_keys_can_be_disabled`.
*
* @return `[refusal, refusedKeyId, refusedSecurityLevel, refusedPurpose]`:
* `refusal` 0 = allowed, 1 = last valid authentication key of a
* security level, 2 = last enabled key of a purpose; level (1) and
* purpose (2) are DPP discriminants. Throws [DashSDKException] when the
* wallet or the identity is not loaded.
*/
external fun checkKeysCanBeDisabled(
walletHandle: Long,
identityId: ByteArray,
keyIds: IntArray,
): LongArray

/**
* Raise the limits of key [keyId] of [identityId] (protocol version 14):
* add [addBudget] credits to its total budget when [hasAddBudget], and
Expand Down
Loading
Loading