diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt index 175d24aef34..df8f60a10dc 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt @@ -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 { @@ -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): 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): 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)." + } + } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt index 098d90d8e64..da832ae963a 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt @@ -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 @@ -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 @@ -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. */ @@ -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( + 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 && @@ -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") @@ -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, @@ -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…" diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index 1412fd9424a..c4196a8d67d 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -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 | diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index a328b0e9396..d800738d5fb 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -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 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index 154fd06dbd3..67efde9ae21 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -4,6 +4,7 @@ import org.dashfoundation.dashsdk.wallet.op import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.TransactionsNative @@ -173,6 +174,28 @@ data class IdentityPubkey( } } +/** + * Why the wallet refuses disabling a set of keys — Kotlin mirror of + * platform-wallet's `KeyDisableRefusal` and Swift's + * `ManagedPlatformWallet.KeyDisableRefusal`. + */ +sealed interface KeyDisableRefusal { + /** The key that cannot be disabled. */ + val keyId: Int + + /** DIP-11: [securityLevel] would be left without a valid authentication key. */ + data class LastAuthenticationKey( + override val keyId: Int, + val securityLevel: SecurityLevel, + ) : KeyDisableRefusal + + /** Wallet policy: [purpose] (TRANSFER) would be left without an enabled key able to sign. */ + data class LastEnabledKey( + override val keyId: Int, + val purpose: KeyPurpose, + ) : KeyDisableRefusal +} + /** * Identity add/disable-keys bridge — port of the identity-update slice of * `ManagedPlatformWallet.swift` (`updateIdentity(addPublicKeys:...)`, driven @@ -238,6 +261,37 @@ class IdentityUpdates internal constructor( } } + /** + * Whether the wallet lets an identity update that only disables [keyIds] + * of [identityId] through, judged by Rust against the wallet's identity + * with the policy [update] refuses with: null when it does, otherwise the + * [KeyDisableRefusal]. No added key counts here, so [update] can accept a + * disable this refuses when the same update adds a replacement key. + * Bridges `platform_wallet_check_keys_can_be_disabled`; throws when the + * wallet or the identity is not loaded. + */ + suspend fun checkKeysCanBeDisabled( + walletHandle: Long, + identityId: ByteArray, + keyIds: List, + ): KeyDisableRefusal? = gate.op { + require(identityId.size == 32) { + "identityId must be 32 bytes, got ${identityId.size}" + } + require(keyIds.all { it >= 0 }) { + "every key id must be non-negative, got $keyIds" + } + decodeKeyDisableVerdict( + mapNativeErrors { + TransactionsNative.checkKeysCanBeDisabled( + walletHandle, + identityId, + keyIds.toIntArray(), + ) + }, + ) + } + /** * Raise the limits of key [keyId] of [identityId] (protocol version 14): * add [addBudget] credits to its total budget (and to what is left of @@ -285,3 +339,39 @@ class IdentityUpdates internal constructor( } } } + +/** + * `PlatformWalletFFIResultCode::ErrorInvalidParameter`, which reaches Kotlin as + * [DashSdkError.PlatformWallet.Generic]. + */ +private const val PLATFORM_WALLET_INVALID_PARAMETER_CODE = 2 + +/** + * Decode the `[refusal, refusedKeyId, refusedSecurityLevel, refusedPurpose]` + * verdict of [TransactionsNative.checkKeysCanBeDisabled]. A value this SDK does + * not know is the typed error a native invalid parameter is, not an allowance. + */ +internal fun decodeKeyDisableVerdict(verdict: LongArray): KeyDisableRefusal? { + val keyId = verdict[1].toInt() + return when (verdict[0]) { + 0L -> null + 1L -> KeyDisableRefusal.LastAuthenticationKey( + keyId = keyId, + securityLevel = SecurityLevel.entries.firstOrNull { it.ffiValue.toLong() == verdict[2] } + ?: throw invalidKeyDisableVerdict( + "unknown security level ${verdict[2]} in a key-disable refusal", + ), + ) + 2L -> KeyDisableRefusal.LastEnabledKey( + keyId = keyId, + purpose = KeyPurpose.entries.firstOrNull { it.ffiValue.toLong() == verdict[3] } + ?: throw invalidKeyDisableVerdict( + "unknown key purpose ${verdict[3]} in a key-disable refusal", + ), + ) + else -> throw invalidKeyDisableVerdict("unknown key-disable refusal kind ${verdict[0]}") + } +} + +private fun invalidKeyDisableVerdict(message: String): DashSdkError = + DashSdkError.PlatformWallet.Generic(PLATFORM_WALLET_INVALID_PARAMETER_CODE, message) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/KeyDisableVerdictTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/KeyDisableVerdictTest.kt new file mode 100644 index 00000000000..aec9ee6d4c0 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/KeyDisableVerdictTest.kt @@ -0,0 +1,41 @@ +package org.dashfoundation.dashsdk.identity + +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class KeyDisableVerdictTest { + + @Test + fun `should decode an allowed verdict`() { + assertNull(decodeKeyDisableVerdict(longArrayOf(0, 0, 0, 0))) + } + + @Test + fun `should decode both refusals`() { + assertEquals( + KeyDisableRefusal.LastAuthenticationKey(1, SecurityLevel.CRITICAL), + decodeKeyDisableVerdict(longArrayOf(1, 1, 1, 0)), + ) + assertEquals( + KeyDisableRefusal.LastEnabledKey(3, KeyPurpose.TRANSFER), + decodeKeyDisableVerdict(longArrayOf(2, 3, 0, 3)), + ) + } + + @Test + fun `should raise the typed SDK error for values it does not know`() { + listOf( + longArrayOf(9, 1, 0, 0), + longArrayOf(1, 1, 99, 0), + longArrayOf(2, 1, 0, 99), + ).forEach { verdict -> + val error = assertThrows(DashSdkError.PlatformWallet.Generic::class.java) { + decodeKeyDisableVerdict(verdict) + } + assertEquals(2, error.nativeCode) + } + } +} diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 647a35ea910..ba0045d01ca 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -1085,6 +1085,11 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::Sdk(dash_sdk::Error::Protocol( dpp::ProtocolError::LastAuthenticationKey(_), )) => PlatformWalletFFIResultCode::ErrorInvalidParameter, + // The wallet's refusal to disable the last enabled TRANSFER key: + // the same class as the DIP-11 refusal above. + PlatformWalletError::LastEnabledKey(_) => { + PlatformWalletFFIResultCode::ErrorInvalidParameter + } // A second producer of code 31 (the arm above is the first), // reached without any marker inspection at this layer: message // signing found no signable account for the address, or the signer @@ -2047,6 +2052,22 @@ mod tests { assert!(msg.contains("cannot disable authentication key 1"), "{msg}"); } + /// The wallet's last-transfer-key refusal is a caller-argument error too. + #[test] + fn last_enabled_key_refusal_maps_to_invalid_parameter() { + let err = PlatformWalletError::LastEnabledKey( + platform_wallet::wallet::identity::LastEnabledKeyError { + key_id: 3, + purpose: dpp::identity::Purpose::TRANSFER, + }, + ); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + } + /// The four DPNS-marketplace trade rejections each map to their own /// dedicated code rather than flattening to `ErrorUnknown`, and the /// not-found case rides the existing `NotFound`. Hosts branch on these diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 32b7de4c8bb..4f65efb16d0 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -26,6 +26,7 @@ use dpp::state_transition::{StateTransition, StateTransitionType}; use platform_wallet::wallet::identity::network::{ check_document_type_name, decode_state_transition, }; +use platform_wallet::wallet::identity::KeyDisableRefusal; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; @@ -384,6 +385,96 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( PlatformWalletFFIResult::ok() } +/// Verdict of [`platform_wallet_check_keys_can_be_disabled`]. +#[repr(C)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct KeyDisableCheckFFI { + /// 0 = every listed key may be disabled; 1 = refused, the last valid + /// authentication key of a security level (DIP-11); 2 = refused, the + /// last enabled key able to sign of a purpose the wallet keeps one of + /// (TRANSFER). + pub refusal: u8, + /// When refused: the key that cannot be disabled. + pub refused_key_id: u32, + /// When `refusal` is 1: the security level (DPP discriminant, 0 = + /// MASTER) that would be left without a valid authentication key. + pub refused_security_level: u8, + /// When `refusal` is 2: the purpose (DPP discriminant, 3 = TRANSFER) + /// that would be left without an enabled key able to sign. + pub refused_purpose: u8, +} + +impl From> for KeyDisableCheckFFI { + fn from(verdict: Result<(), KeyDisableRefusal>) -> Self { + match verdict { + Ok(()) => Self::default(), + Err(KeyDisableRefusal::LastAuthenticationKey(refusal)) => Self { + refusal: 1, + refused_key_id: refusal.key_id, + refused_security_level: refusal.security_level as u8, + ..Self::default() + }, + Err(KeyDisableRefusal::LastEnabledKey(refusal)) => Self { + refusal: 2, + refused_key_id: refusal.key_id, + refused_purpose: refusal.purpose as u8, + ..Self::default() + }, + } + } +} + +/// Whether the wallet lets an identity update that only disables +/// `disable_public_key_ids` of its identity `identity_id` through: no security +/// level loses its last valid authentication key (DIP-11) and the identity +/// keeps an enabled TRANSFER key able to sign. +/// +/// Marshals `IdentityWallet::check_keys_can_be_disabled`, which judges the +/// identity [`platform_wallet_update_identity_with_signer`] builds the update +/// from with the policy that entry point refuses with. No added key counts +/// here, so that entry point can accept a disable this refuses when the same +/// update adds a replacement key. A refusal is a verdict, written to +/// `out_check` with a success result; an error result means a null key list +/// with a nonzero count, or an unknown wallet handle or identity. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_check_keys_can_be_disabled( + wallet_handle: Handle, + identity_id: *const u8, + disable_public_key_ids: *const u32, + disable_public_key_ids_count: usize, + out_check: *mut KeyDisableCheckFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_check); + + let id = unwrap_result_or_return!(read_identifier(identity_id)); + if disable_public_key_ids.is_null() && disable_public_key_ids_count != 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "disable_public_key_ids is null with a count of {disable_public_key_ids_count}" + ), + ); + } + let disable_ids: Vec = if disable_public_key_ids_count == 0 { + Vec::new() + } else { + slice::from_raw_parts(disable_public_key_ids, disable_public_key_ids_count).to_vec() + }; + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + block_on_worker(async move { + identity_wallet + .check_keys_can_be_disabled(&id, &disable_ids) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let verdict = unwrap_result_or_return!(result); + *out_check = verdict.into(); + PlatformWalletFFIResult::ok() +} + #[cfg(test)] mod tests { use super::*; @@ -722,3 +813,169 @@ mod tests { assert_eq!(out.disable_public_key_ids_count, 0); } } + +#[cfg(test)] +mod key_disable_check_tests { + use super::*; + use crate::runtime::runtime; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use platform_wallet::test_support::test_platform_wallet_manager; + use platform_wallet::wallet::persister::NoPlatformPersistence; + use platform_wallet::WalletPersister; + use std::collections::BTreeMap; + use std::sync::Arc; + + const IDENTITY_ID: [u8; 32] = [1; 32]; + + /// MASTER 0, CRITICAL 1, HIGH 2 authentication keys and TRANSFER 3. + fn identity() -> Identity { + let keys = [ + (0, Purpose::AUTHENTICATION, SecurityLevel::MASTER), + (1, Purpose::AUTHENTICATION, SecurityLevel::CRITICAL), + (2, Purpose::AUTHENTICATION, SecurityLevel::HIGH), + (3, Purpose::TRANSFER, SecurityLevel::CRITICAL), + ] + .map(|(id, purpose, security_level)| { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![id as u8 + 2; 33]), + disabled_at: None, + }) + }); + IdentityV0 { + id: Identifier::from(IDENTITY_ID), + public_keys: keys + .into_iter() + .map(|key| (key.id(), key)) + .collect::>(), + balance: 0, + revision: 0, + } + .into() + } + + unsafe fn check( + handle: Handle, + identity_id: &[u8; 32], + key_ids: &[u32], + ) -> (PlatformWalletFFIResult, KeyDisableCheckFFI) { + let mut out = KeyDisableCheckFFI::default(); + let result = platform_wallet_check_keys_can_be_disabled( + handle, + identity_id.as_ptr(), + key_ids.as_ptr(), + key_ids.len(), + &mut out, + ); + (result, out) + } + + #[test] + fn should_report_the_wallets_verdict_for_its_identity() { + // Setup only inside the runtime: the FFI entry point blocks on its own + // worker and must not run nested in another runtime. + let (manager, handle) = runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + { + let persister = WalletPersister::new(wallet_id, Arc::new(NoPlatformPersistence)); + let mut wm = wallet.identity().wallet_manager_write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("wallet info") + .identity_manager + .add_identity(identity(), 0, wallet_id, &persister) + .expect("add identity"); + } + (manager, PLATFORM_WALLET_STORAGE.insert(wallet)) + }); + + let (result, refused) = unsafe { check(handle, &IDENTITY_ID, &[1]) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + refused, + KeyDisableCheckFFI { + refusal: 1, + refused_key_id: 1, + refused_security_level: SecurityLevel::CRITICAL as u8, + refused_purpose: 0, + } + ); + + let (result, refused) = unsafe { check(handle, &IDENTITY_ID, &[3]) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + refused, + KeyDisableCheckFFI { + refusal: 2, + refused_key_id: 3, + refused_security_level: 0, + refused_purpose: Purpose::TRANSFER as u8, + } + ); + + // CRITICAL 1 stands in for HIGH 2. + let (result, allowed) = unsafe { check(handle, &IDENTITY_ID, &[2]) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(allowed, KeyDisableCheckFFI::default()); + + // A null list of no keys is a legitimate "disable nothing". + let mut none_refused = KeyDisableCheckFFI::default(); + let result = unsafe { + platform_wallet_check_keys_can_be_disabled( + handle, + IDENTITY_ID.as_ptr(), + ptr::null(), + 0, + &mut none_refused, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(none_refused, KeyDisableCheckFFI::default()); + + // An identity the wallet does not hold is an error, not a verdict. + let (result, _) = unsafe { check(handle, &[9; 32], &[1]) }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + + PLATFORM_WALLET_STORAGE.remove(handle); + drop(manager); + } + + #[test] + fn should_refuse_a_null_key_list_with_a_nonzero_count() { + let mut out = KeyDisableCheckFFI::default(); + let result = unsafe { + platform_wallet_check_keys_can_be_disabled( + 0, + IDENTITY_ID.as_ptr(), + ptr::null(), + 1, + &mut out, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + } + + #[test] + fn should_refuse_a_null_out_pointer() { + let result = unsafe { + platform_wallet_check_keys_can_be_disabled( + 0, + IDENTITY_ID.as_ptr(), + ptr::null(), + 0, + ptr::null_mut(), + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } +} diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 5696d681d2b..4a30692b04f 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -704,6 +704,15 @@ pub enum PlatformWalletError { #[error("Invalid parameter: {0}")] InvalidParameter(String), + /// An identity update would disable the last enabled key able to sign of + /// a purpose the wallet keeps one of (TRANSFER), refused before anything is + /// fetched or signed. Wallet policy, not consensus; the DIP-11 + /// last-authentication-key refusal travels as + /// `Sdk(Protocol(LastAuthenticationKey))` instead. FFI maps it to the + /// invalid-parameter code. + #[error(transparent)] + LastEnabledKey(#[from] crate::wallet::identity::LastEnabledKeyError), + /// The SPV masternode list has not been synced yet, so a /// masternode-list-dependent action (locating an entry, re-asserting its /// service values) cannot proceed. FFI maps it to the same @@ -1055,6 +1064,21 @@ impl From for PlatformWalletError { } } +impl From for PlatformWalletError { + /// The DIP-11 refusal keeps the shape dpp's identity update builder gives + /// it, `Sdk(Protocol(LastAuthenticationKey))`, so callers see one error + /// whichever of the two refuses first. + fn from(refusal: crate::wallet::identity::KeyDisableRefusal) -> Self { + use crate::wallet::identity::KeyDisableRefusal; + match refusal { + KeyDisableRefusal::LastAuthenticationKey(refusal) => Self::Sdk( + dash_sdk::Error::Protocol(dpp::ProtocolError::LastAuthenticationKey(refusal)), + ), + KeyDisableRefusal::LastEnabledKey(refusal) => Self::LastEnabledKey(refusal), + } + } +} + impl PlatformWalletError { /// A persister `load` failed. /// diff --git a/packages/rs-platform-wallet/src/wallet/identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/mod.rs index 01079577e1b..27dbb1bae1e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/mod.rs @@ -31,7 +31,8 @@ pub use crypto::{ }; pub use network::{DashPayView, IdentityWallet}; pub use state::{ - BlockTime, DashPayState, IdentityLocation, IdentityManager, ManagedIdentity, RegistrationIndex, + BlockTime, DashPayState, IdentityLocation, IdentityManager, KeyDisableRefusal, + LastEnabledKeyError, ManagedIdentity, RegistrationIndex, }; pub use types::dashpay::profile::{calculate_avatar_hash, calculate_dhash_fingerprint}; pub use types::{ diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index aa738e5b017..00e3cb4fc10 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -8,6 +8,7 @@ use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; +use dpp::identity::KeyID; use dpp::identity::KeyType; use dpp::identity::Purpose; use dpp::identity::SecurityLevel; @@ -20,6 +21,7 @@ use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; use crate::error::PlatformWalletError; +use crate::wallet::identity::state::{check_keys_can_be_disabled, KeyDisableRefusal}; use super::signing_key::AvailableSigningKey; use super::*; @@ -61,6 +63,44 @@ where // --------------------------------------------------------------------------- impl IdentityWallet { + /// Whether this wallet lets an identity update that only disables + /// `disable_public_keys` of the managed identity `identity_id` through: + /// the inner `Err` is the [`KeyDisableRefusal`] — the last valid + /// authentication key of a security level (DIP-11), or the last enabled + /// TRANSFER key able to sign. + /// + /// Judges the identity this wallet holds, which is the one + /// [`Self::update_identity_with_external_signer`] builds the update from, + /// with the same [`check_keys_can_be_disabled`] the update entry points + /// refuse with. No added key counts here, so an update can go through + /// where this refuses when it also adds a replacement key. Like the entry + /// points it reads no clock: an authentication key with any expiry does + /// not count as remaining. + /// + /// The outer `Err` is a wallet failure: the wallet or the identity is not + /// loaded. + pub async fn check_keys_can_be_disabled( + &self, + identity_id: &Identifier, + disable_public_keys: &[KeyID], + ) -> Result, PlatformWalletError> { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let managed = info + .identity_manager + .identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + Ok(check_keys_can_be_disabled( + &managed.identity, + &[], + disable_public_keys, + )) + } + /// Update an identity using an externally-supplied signer. /// /// Signing is routed through the supplied `&S: Signer`. @@ -71,11 +111,13 @@ impl IdentityWallet { /// identity-update state transition requires (DPP gates this on /// MASTER specifically — HIGH/CRITICAL aren't accepted). /// - /// An update that would disable the last valid authentication key of a - /// security level is refused by the transition builder before anything is - /// signed or broadcast, with + /// An update [`check_keys_can_be_disabled`] refuses is refused before a + /// nonce is fetched or anything is signed: disabling the last valid + /// authentication key of a security level with /// `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(ProtocolError::LastAuthenticationKey(_)))` - /// (DIP-11); keys added in the same update count as valid. + /// (DIP-11), the last enabled TRANSFER key able to sign with + /// `PlatformWalletError::LastEnabledKey(_)`. Keys added in the same update + /// count as remaining. /// /// NOTE: callers that ADD keys via `add_public_keys` are /// responsible for pre-persisting the new keys' private material @@ -136,6 +178,10 @@ impl IdentityWallet { (identity, index) }; + // Refuse a disable this wallet does not allow before fetching a nonce + // or signing anything. + check_keys_can_be_disabled(&identity, &add_public_keys, &disable_public_keys)?; + // Increment revision for the update transition. let original_revision = identity.revision(); identity.set_revision(original_revision + 1); @@ -294,11 +340,9 @@ impl IdentityWallet { /// caller receives the chain's error. See /// `docs/protocol/authentication-key-limits.md`. /// - /// An update that would disable the last valid authentication key of a - /// security level is refused by the transition builder before anything is - /// signed or broadcast, with - /// `dash_sdk::Error::Protocol(ProtocolError::LastAuthenticationKey(_))` - /// (DIP-11). + /// An update [`check_keys_can_be_disabled`] refuses is refused before a + /// nonce is fetched or anything is signed, with the same errors as + /// [`Self::update_identity_with_external_signer`]. /// /// Returns the [`StateTransitionProofResult`] from the broadcast so callers /// can inspect proof-verified outcomes (e.g. updated keys, balance). @@ -310,12 +354,14 @@ impl IdentityWallet { disable_public_keys: Vec, signer: &S, settings: Option, - ) -> Result + ) -> Result { use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; + check_keys_can_be_disabled(identity, &add_public_keys, &disable_public_keys)?; + // Get identity nonce from Platform. let identity_nonce = self .sdk @@ -339,7 +385,7 @@ impl IdentityWallet { None, ) .await - .map_err(dash_sdk::Error::Protocol)?; + .map_err(|e| PlatformWalletError::Sdk(dash_sdk::Error::Protocol(e)))?; // Broadcast and wait for confirmation. let result = state_transition @@ -354,10 +400,12 @@ impl IdentityWallet { mod tests { use super::*; use crate::test_support::{test_platform_wallet_manager_with_sdk, NoopTestPersister}; + use crate::wallet::identity::LastEnabledKeyError; use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use crate::wallet::platform_wallet::WalletId; use dash_sdk::query_types::IdentityNonceFetcher; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::key_disable_rule::LastAuthenticationKeyError; use dpp::identity::v0::IdentityV0; use std::collections::BTreeMap; use std::sync::Arc; @@ -477,6 +525,121 @@ mod tests { ); } + async fn manager_holding( + identity: Identity, + ) -> ( + Arc>, + Arc, + ) { + let (manager, wallet_id) = test_platform_wallet_manager_with_sdk( + dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"), + ) + .await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + { + let persister = WalletPersister::new(wallet_id, Arc::new(NoPlatformPersistence)); + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + info.identity_manager + .add_identity(identity, 0, wallet_id, &persister) + .expect("add identity"); + } + (manager, wallet) + } + + #[tokio::test] + async fn should_judge_a_disable_against_the_managed_identity() { + let (_manager, wallet) = manager_holding(identity()).await; + let identity_id = Identifier::from([1; 32]); + + let refused = wallet + .identity() + .check_keys_can_be_disabled(&identity_id, &[1]) + .await + .expect("the identity is loaded"); + assert_eq!( + refused, + Err(KeyDisableRefusal::LastAuthenticationKey( + LastAuthenticationKeyError { + key_id: 1, + security_level: SecurityLevel::CRITICAL, + } + )) + ); + // CRITICAL 1 stands in for HIGH 2. + assert_eq!( + wallet + .identity() + .check_keys_can_be_disabled(&identity_id, &[2]) + .await + .expect("the identity is loaded"), + Ok(()) + ); + } + + #[tokio::test] + async fn should_not_count_an_expiring_key_like_the_builder() { + // The builder reads no clock and counts no key with an expiry, and + // neither does the verdict: for a disable-only update the two agree. + let mut identity = identity(); + identity.add_public_key( + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 3, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![5; 33]), + disabled_at: None, + }) + .with_limits(None, Some(u64::MAX)), + ); + let (_manager, wallet) = manager_holding(identity.clone()).await; + let identity_id = Identifier::from([1; 32]); + assert_eq!( + wallet + .identity() + .check_keys_can_be_disabled(&identity_id, &[1]) + .await + .expect("the identity is loaded"), + Err(KeyDisableRefusal::LastAuthenticationKey( + LastAuthenticationKeyError { + key_id: 1, + security_level: SecurityLevel::CRITICAL, + } + )) + ); + // The update path refuses the same disable. + let result = wallet + .identity() + .update_identity_with_signer(&identity, &0, vec![], vec![1], &HoldsEveryKey, None) + .await; + assert!( + matches!( + result, + Err(PlatformWalletError::Sdk(dash_sdk::Error::Protocol( + ProtocolError::LastAuthenticationKey(_) + ))) + ), + "{result:?}" + ); + } + + #[tokio::test] + async fn should_report_an_identity_the_wallet_does_not_hold() { + let (_manager, wallet) = manager_holding(identity()).await; + let unknown = Identifier::from([9; 32]); + let result = wallet + .identity() + .check_keys_can_be_disabled(&unknown, &[1]) + .await; + assert!( + matches!(result, Err(PlatformWalletError::IdentityNotFound(id)) if id == unknown), + "{result:?}" + ); + } + /// The caller-supplied-identity entry point refuses the same way. #[tokio::test] async fn should_refuse_disabling_the_last_critical_key_with_a_supplied_identity() { @@ -490,10 +653,81 @@ mod tests { assert!( matches!( result, - Err(dash_sdk::Error::Protocol(ProtocolError::LastAuthenticationKey(ref refusal))) + Err(PlatformWalletError::Sdk(dash_sdk::Error::Protocol( + ProtocolError::LastAuthenticationKey(ref refusal) + ))) if refusal.key_id == 1 && refusal.security_level == SecurityLevel::CRITICAL ), "{result:?}" ); } + + fn transfer_key(id: u32) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::TRANSFER, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![id as u8 + 2; 33]), + disabled_at: None, + }) + } + + #[tokio::test] + async fn should_judge_the_last_transfer_key_as_wallet_policy() { + let mut identity = identity(); + identity.add_public_key(transfer_key(3)); + let (_manager, wallet) = manager_holding(identity).await; + assert_eq!( + wallet + .identity() + .check_keys_can_be_disabled(&Identifier::from([1; 32]), &[3]) + .await + .expect("the identity is loaded"), + Err(KeyDisableRefusal::LastEnabledKey(LastEnabledKeyError { + key_id: 3, + purpose: Purpose::TRANSFER, + })) + ); + } + + /// Both update entry points refuse the last transfer key before any + /// network call: the mock SDK has no expectations, so a nonce fetch would + /// fail with a different error. + #[tokio::test] + async fn should_refuse_disabling_the_last_transfer_key_before_fetching_a_nonce() { + let mut identity = identity(); + identity.add_public_key(transfer_key(3)); + let (_manager, wallet) = manager_holding(identity.clone()).await; + + let result = wallet + .identity() + .update_identity_with_external_signer( + &Identifier::from([1; 32]), + vec![], + vec![3], + &HoldsEveryKey, + None, + ) + .await; + assert!( + matches!( + result, + Err(PlatformWalletError::LastEnabledKey(ref refusal)) + if refusal.key_id == 3 && refusal.purpose == Purpose::TRANSFER + ), + "{result:?}" + ); + + let result = wallet + .identity() + .update_identity_with_signer(&identity, &0, vec![], vec![3], &HoldsEveryKey, None) + .await; + assert!( + matches!(result, Err(PlatformWalletError::LastEnabledKey(_))), + "{result:?}" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs b/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs new file mode 100644 index 00000000000..8df057fb582 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs @@ -0,0 +1,299 @@ +//! Which of an identity's keys this wallet lets an identity update disable. +//! +//! Two refusals, both client policy that Platform does not enforce: +//! +//! - **DIP-11** — the last valid authentication key of a security level, +//! judged by dpp's [`dpp_check_keys_can_be_disabled`]. +//! - **Last enabled TRANSFER key able to sign** — wallet policy, not a DIP +//! rule: without one the identity can no longer withdraw or transfer credits +//! until a new transfer key is added. +//! +//! [`check_keys_can_be_disabled`] is the single place both are decided. The +//! identity-update entry points refuse with it before fetching a nonce, and +//! hosts ask it through `IdentityWallet::check_keys_can_be_disabled` to offer +//! only the keys it allows. + +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::key_disable_rule::{ + check_keys_can_be_disabled as dpp_check_keys_can_be_disabled, LastAuthenticationKeyError, +}; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose, SecurityLevel}; + +/// Disabling `key_id` would leave the identity without an enabled key of +/// `purpose` able to sign. Wallet policy, not a DIP rule or consensus: +/// Platform accepts the update. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error( + "cannot disable {purpose} key {key_id}: no other enabled {purpose} key able to sign \ + withdrawals and transfers would remain" +)] +pub struct LastEnabledKeyError { + /// The key the update would disable. + pub key_id: KeyID, + /// The purpose that would be left without an enabled key able to sign. + pub purpose: Purpose, +} + +/// Why this wallet refuses an identity update's key disables. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum KeyDisableRefusal { + /// DIP-11: the last valid authentication key of a security level. + #[error(transparent)] + LastAuthenticationKey(#[from] LastAuthenticationKeyError), + /// Wallet policy: the last enabled key of a purpose that must keep one. + #[error(transparent)] + LastEnabledKey(#[from] LastEnabledKeyError), +} + +/// Purposes an identity must keep an enabled key of. TRANSFER only: it +/// authorizes credit withdrawals and transfers. +const PURPOSES_KEPT_ENABLED: [Purpose; 1] = [Purpose::TRANSFER]; + +/// Whether identity-signed validation lets `key` sign a credit withdrawal or +/// transfer, both of which require a CRITICAL key. `ECDSA_HASH160` keys, which +/// identities created by older Dash Evo Tool versions carry, can. +fn can_sign_withdrawals_and_transfers(key: &IdentityPublicKey) -> bool { + key.security_level() == SecurityLevel::CRITICAL && key.key_type().can_sign_state_transitions() +} + +/// Whether an identity update that adds `add_public_keys` and disables +/// `disable_public_keys` is one this wallet lets through: DIP-11 first (see +/// [`dpp_check_keys_can_be_disabled`] for what counts as a valid +/// authentication key), then the last enabled TRANSFER key able to sign. A +/// TRANSFER key that cannot sign neither counts as remaining nor is protected: +/// disabling it costs no ability. A key added in the same update counts as +/// remaining for both. +/// +/// No clock, exactly like dpp's identity update builder: an authentication +/// key with any expiry does not count as remaining. So for the same keys, the +/// answer hosts are shown and the one the update gets are the same. +pub fn check_keys_can_be_disabled( + identity: &Identity, + add_public_keys: &[IdentityPublicKey], + disable_public_keys: &[KeyID], +) -> Result<(), KeyDisableRefusal> { + dpp_check_keys_can_be_disabled(identity, add_public_keys, disable_public_keys, None)?; + + for key_id in disable_public_keys { + let Some(key) = identity.public_keys().get(key_id) else { + continue; + }; + let purpose = key.purpose(); + if !PURPOSES_KEPT_ENABLED.contains(&purpose) + || key.disabled_at().is_some() + || !can_sign_withdrawals_and_transfers(key) + { + continue; + } + let another_remains = identity + .public_keys() + .values() + .filter(|remaining| !disable_public_keys.contains(&remaining.id())) + .chain(add_public_keys) + .any(|remaining| { + remaining.purpose() == purpose + && remaining.disabled_at().is_none() + && can_sign_withdrawals_and_transfers(remaining) + }); + if !another_remains { + return Err(LastEnabledKeyError { + key_id: *key_id, + purpose, + } + .into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{KeyType, SecurityLevel}; + use dpp::platform_value::{BinaryData, Identifier}; + use std::collections::BTreeMap; + + fn key(id: KeyID, purpose: Purpose, security_level: SecurityLevel) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![id as u8 + 2; 33]), + disabled_at: None, + }) + } + + /// MASTER 0, CRITICAL 1 authentication keys and TRANSFER 3. + fn identity_with(extra: Vec) -> Identity { + let keys = [ + key(0, Purpose::AUTHENTICATION, SecurityLevel::MASTER), + key(1, Purpose::AUTHENTICATION, SecurityLevel::CRITICAL), + key(3, Purpose::TRANSFER, SecurityLevel::CRITICAL), + ] + .into_iter() + .chain(extra); + IdentityV0 { + id: Identifier::from([1; 32]), + public_keys: keys.map(|key| (key.id(), key)).collect::>(), + balance: 0, + revision: 0, + } + .into() + } + + #[test] + fn should_refuse_disabling_the_last_enabled_transfer_key() { + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![]), &[], &[3]), + Err(KeyDisableRefusal::LastEnabledKey(LastEnabledKeyError { + key_id: 3, + purpose: Purpose::TRANSFER, + })) + ); + } + + #[test] + fn should_allow_disabling_a_transfer_key_another_enabled_one_replaces() { + let second = key(4, Purpose::TRANSFER, SecurityLevel::CRITICAL); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![second.clone()]), &[], &[3]), + Ok(()) + ); + // A transfer key added in the same update counts. + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![]), &[second], &[3]), + Ok(()) + ); + } + + #[test] + fn should_not_count_a_disabled_transfer_key_as_remaining() { + let mut disabled = key(4, Purpose::TRANSFER, SecurityLevel::CRITICAL); + disabled.set_disabled_at(1_700_000_000_000); + assert!(matches!( + check_keys_can_be_disabled(&identity_with(vec![disabled]), &[], &[3]), + Err(KeyDisableRefusal::LastEnabledKey(_)) + )); + // Disabling both transfer keys at once leaves none. + let second = key(4, Purpose::TRANSFER, SecurityLevel::CRITICAL); + assert!(matches!( + check_keys_can_be_disabled(&identity_with(vec![second]), &[], &[3, 4]), + Err(KeyDisableRefusal::LastEnabledKey(_)) + )); + } + + fn transfer_key(id: KeyID, key_type: KeyType, level: SecurityLevel) -> IdentityPublicKey { + let data = match key_type { + KeyType::ECDSA_SECP256K1 => vec![id as u8 + 2; 33], + KeyType::BLS12_381 => vec![id as u8 + 2; 48], + _ => vec![id as u8 + 2; 20], + }; + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::TRANSFER, + security_level: level, + contract_bounds: None, + key_type, + read_only: false, + data: BinaryData::new(data), + disabled_at: None, + }) + } + + #[test] + fn should_not_count_a_transfer_key_that_cannot_sign_as_remaining() { + for key_type in [KeyType::BIP13_SCRIPT_HASH, KeyType::EDDSA_25519_HASH160] { + let unusable = transfer_key(4, key_type, SecurityLevel::CRITICAL); + let refused = Err(KeyDisableRefusal::LastEnabledKey(LastEnabledKeyError { + key_id: 3, + purpose: Purpose::TRANSFER, + })); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![unusable.clone()]), &[], &[3]), + refused, + "{key_type:?} held by the identity" + ); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![]), &[unusable], &[3]), + refused, + "{key_type:?} added in the same update" + ); + } + } + + #[test] + fn should_not_count_a_transfer_key_below_critical_as_remaining() { + let high = transfer_key(4, KeyType::ECDSA_SECP256K1, SecurityLevel::HIGH); + assert!(matches!( + check_keys_can_be_disabled(&identity_with(vec![high]), &[], &[3]), + Err(KeyDisableRefusal::LastEnabledKey(_)) + )); + } + + #[test] + fn should_count_a_hash160_transfer_key_as_remaining() { + let hash160 = transfer_key(4, KeyType::ECDSA_HASH160, SecurityLevel::CRITICAL); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![hash160.clone()]), &[], &[3]), + Ok(()) + ); + // And the last one is protected like any other that can sign. + let only_hash160 = transfer_key(3, KeyType::ECDSA_HASH160, SecurityLevel::CRITICAL); + assert!(matches!( + check_keys_can_be_disabled(&identity_with(vec![only_hash160]), &[], &[3]), + Err(KeyDisableRefusal::LastEnabledKey(_)) + )); + } + + #[test] + fn should_allow_disabling_a_transfer_key_that_cannot_sign() { + // Key id 3 replaces the signing TRANSFER key of `identity_with`, so this + // is the identity's only TRANSFER key and disabling it costs no ability. + let only_unusable = transfer_key(3, KeyType::BIP13_SCRIPT_HASH, SecurityLevel::CRITICAL); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![only_unusable]), &[], &[3]), + Ok(()) + ); + } + + #[test] + fn should_say_the_remaining_transfer_key_must_be_able_to_sign() { + let unusable = transfer_key(4, KeyType::BIP13_SCRIPT_HASH, SecurityLevel::CRITICAL); + let refusal = check_keys_can_be_disabled(&identity_with(vec![unusable]), &[], &[3]) + .expect_err("an unusable key does not replace the last one that can sign"); + assert_eq!( + refusal.to_string(), + "cannot disable TRANSFER key 3: no other enabled TRANSFER key able to sign \ + withdrawals and transfers would remain" + ); + } + + #[test] + fn should_report_the_dip11_refusal_for_authentication_keys() { + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![]), &[], &[1]), + Err(KeyDisableRefusal::LastAuthenticationKey( + LastAuthenticationKeyError { + key_id: 1, + security_level: SecurityLevel::CRITICAL, + } + )) + ); + } + + #[test] + fn should_allow_disabling_keys_of_other_purposes() { + let encryption = key(5, Purpose::ENCRYPTION, SecurityLevel::MEDIUM); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![encryption]), &[], &[5]), + Ok(()) + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs index affa70d0156..896da298df9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs @@ -4,10 +4,13 @@ //! mutation impls that maintain its invariants (identity-side + //! DashPay-side both live here today). //! - `manager/`: the per-wallet collection of managed identities. +//! - `key_disable`: which keys an identity update may disable. +pub mod key_disable; pub mod managed_identity; pub mod manager; +pub use key_disable::{check_keys_can_be_disabled, KeyDisableRefusal, LastEnabledKeyError}; pub use managed_identity::{BlockTime, DashPayState, ManagedIdentity}; pub use manager::IdentityLocation; pub use manager::IdentityManager; diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index c9347bedfc8..6c09bb79dad 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -16,6 +16,8 @@ //! same screen: //! * identity add/disable keys -> `platform_wallet_update_identity_with_signer` //! (`AddIdentityKeyView.submit` -> `wallet.updateIdentity(...)`), +//! * whether keys may be disabled -> `platform_wallet_check_keys_can_be_disabled` +//! (`KeyDisableGate` via `wallet.checkKeysCanBeDisabled(...)`), //! * document purchase / set-price -> //! `platform_wallet_document_purchase` / `platform_wallet_document_set_price` //! (`ManagedPlatformWallet.purchaseDocument`), @@ -40,11 +42,12 @@ use crate::pubkey_rows::decode_update_pubkeys_blob; use crate::support::{guard, net_from_ord, take_pwffi_error, throw_sdk_exception}; -use jni::objects::{JByteArray, JClass, JString}; -use jni::sys::{jboolean, jint, jlong, jstring}; +use jni::objects::{JByteArray, JClass, JIntArray, JString}; +use jni::sys::{jboolean, jint, jlong, jlongArray, jstring}; use jni::JNIEnv; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_registration_with_signer::IdentityPubkeyFFI; +use platform_wallet_ffi::identity_update::KeyDisableCheckFFI; use rs_sdk_ffi::SignerHandle; use std::ffi::{CStr, CString}; use std::os::raw::c_char; @@ -132,6 +135,30 @@ fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { // ── Identity update (add / disable keys) ────────────────────────────── +/// Read key ids from a JVM `int[]` into a `Vec`. Only a successful read +/// of an empty array means "no keys"; jni 0.21 has no `convert_int_array`, so +/// read the length then the region into an owned buffer. Throws + returns +/// `None` on a null array or a failed read, and on a negative id, which would +/// otherwise bit-cast to a bogus huge `u32`. +fn read_key_ids(env: &mut JNIEnv, key_ids: &JIntArray) -> Option> { + let read = (|| { + let len = usize::try_from(env.get_array_length(key_ids).ok()?).ok()?; + let mut buf = vec![0i32; len]; + env.get_int_array_region(key_ids, 0, &mut buf).ok()?; + Some(buf) + })(); + let Some(buf) = read else { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "keyIds int[] could not be read"); + return None; + }; + if buf.iter().any(|&i| i < 0) { + throw_sdk_exception(env, 1, "keyIds must be non-negative"); + return None; + } + Some(buf.into_iter().map(|i| i as u32).collect()) +} + /// Add public keys and/or disable existing key ids on an identity, signing /// the resulting `IdentityUpdateTransition` via the external `signerHandle` /// (the identity's MASTER auth key) — the exact call @@ -177,7 +204,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up wallet_handle: jlong, identity_id: JByteArray, add_pubkeys_blob: JByteArray, - disable_public_key_ids: jni::objects::JIntArray, + disable_public_key_ids: JIntArray, signer_handle: jlong, ) { guard(&mut env, (), |env| { @@ -192,37 +219,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up return; }; - // Read the disable ids (a JVM int[] → Vec). A null int[] is a - // legitimate "no disables"; jni 0.21 has no `convert_int_array`, so - // read the length then the region into an owned buffer. - let disable_ids: Vec = if disable_public_key_ids.is_null() { - Vec::new() - } else { - match env.get_array_length(&disable_public_key_ids) { - Ok(len) if len > 0 => { - let mut buf = vec![0i32; len as usize]; - if env - .get_int_array_region(&disable_public_key_ids, 0, &mut buf) - .is_err() - { - let _ = env.exception_clear(); - Vec::new() - } else { - // Reject negative entries before the sign-losing - // cast — a negative int would otherwise bit-cast - // to a bogus huge u32 key id. - if buf.iter().any(|&i| i < 0) { - throw_sdk_exception(env, 1, "keyIds must be non-negative"); - return; - } - buf.into_iter().map(|i| i as u32).collect() - } - } - _ => { - let _ = env.exception_clear(); - Vec::new() - } - } + let Some(disable_ids) = read_key_ids(env, &disable_public_key_ids) else { + return; }; if decoded.is_empty() && disable_ids.is_empty() { @@ -262,6 +260,65 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up }) } +/// Whether the wallet lets an identity update disable `keyIds` of its +/// identity — 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. Thin marshaler over +/// `platform_wallet_check_keys_can_be_disabled`. +/// +/// Returns a `long[4]`: `[refusal, refusedKeyId, refusedSecurityLevel, +/// refusedPurpose]`. `refusal` is 0 when allowed, 1 for the last valid +/// authentication key of a security level, 2 for the last enabled key of a +/// purpose; the level (1) and purpose (2) are DPP discriminants. Throws when +/// the wallet or the identity is not loaded. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_checkKeysCanBeDisabled( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, + key_ids: JIntArray, +) -> jlongArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let Some(key_ids) = read_key_ids(env, &key_ids) else { + return ptr::null_mut(); + }; + let mut check = KeyDisableCheckFFI::default(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_check_keys_can_be_disabled( + wallet_handle as Handle, + id.as_ptr(), + if key_ids.is_empty() { + ptr::null() + } else { + key_ids.as_ptr() + }, + key_ids.len(), + &mut check, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let out = [ + jlong::from(check.refusal), + jlong::from(check.refused_key_id), + jlong::from(check.refused_security_level), + jlong::from(check.refused_purpose), + ]; + let Ok(arr) = env.new_long_array(4) else { + return ptr::null_mut(); + }; + if env.set_long_array_region(&arr, 0, &out).is_err() { + return ptr::null_mut(); + } + arr.into_raw() + }) +} + /// Raise the limits of one of the identity's keys through /// `platform_wallet_update_identity_key_limits_with_signer`: add /// `addBudget` credits to its total budget (and to what is left of it) when @@ -952,3 +1009,290 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ca // `success(null)`), so nothing else to release here. }) } + +#[cfg(test)] +mod read_key_ids_tests { + //! No JVM on the test hosts: these drive the real exports through a JNI + //! function table that scripts the array reads and records what is thrown. + + use super::*; + use jni::sys::{ + jarray, jboolean, jbyte, jbyteArray, jclass, jint, jintArray, jmethodID, jobject, jsize, + jstring, jthrowable, jvalue, JNIEnv as RawEnv, JNINativeInterface_, JNI_FALSE, JNI_TRUE, + }; + use std::cell::RefCell; + + const ID_ARRAY: usize = 0x10; + const BLOB_ARRAY: usize = 0x20; + const KEY_IDS_ARRAY: usize = 0x30; + const READ_FAILED: &str = "keyIds int[] could not be read"; + + /// What the fake JVM holds, and what the code under test did to it. + #[derive(Default)] + struct Fake { + blob: Vec, + key_ids: Vec, + fail_key_ids_read: bool, + exception_pending: bool, + last_string: String, + thrown: Vec, + } + + thread_local! { + static FAKE: RefCell = RefCell::new(Fake::default()); + } + + fn fake(f: impl FnOnce(&mut Fake) -> T) -> T { + FAKE.with(|fake| f(&mut fake.borrow_mut())) + } + + unsafe extern "system" fn get_array_length(_: *mut RawEnv, array: jarray) -> jsize { + match array as usize { + ID_ARRAY => 32, + BLOB_ARRAY => fake(|f| f.blob.len()) as jsize, + _ => fake(|f| f.key_ids.len()) as jsize, + } + } + + unsafe extern "system" fn get_byte_array_region( + _: *mut RawEnv, + array: jbyteArray, + start: jsize, + len: jsize, + buf: *mut jbyte, + ) { + if array as usize == BLOB_ARRAY { + let blob = fake(|f| f.blob.clone()); + ptr::copy_nonoverlapping( + blob[start as usize..].as_ptr().cast::(), + buf, + len as usize, + ); + } else { + ptr::write_bytes(buf, 0, len as usize); + } + } + + unsafe extern "system" fn get_int_array_region( + _: *mut RawEnv, + _: jintArray, + start: jsize, + len: jsize, + buf: *mut jint, + ) { + fake(|f| { + if f.fail_key_ids_read { + f.exception_pending = true; + } else { + ptr::copy_nonoverlapping(f.key_ids[start as usize..].as_ptr(), buf, len as usize); + } + }); + } + + unsafe extern "system" fn exception_check(_: *mut RawEnv) -> jboolean { + if fake(|f| f.exception_pending) { + JNI_TRUE + } else { + JNI_FALSE + } + } + + unsafe extern "system" fn exception_clear(_: *mut RawEnv) { + fake(|f| f.exception_pending = false); + } + + unsafe extern "system" fn find_class(_: *mut RawEnv, _: *const c_char) -> jclass { + 0x40 as jclass + } + + unsafe extern "system" fn get_method_id( + _: *mut RawEnv, + _: jclass, + _: *const c_char, + _: *const c_char, + ) -> jmethodID { + 0x50 as jmethodID + } + + unsafe extern "system" fn new_object_a( + _: *mut RawEnv, + _: jclass, + _: jmethodID, + _: *const jvalue, + ) -> jobject { + 0x60 as jobject + } + + unsafe extern "system" fn new_string_utf(_: *mut RawEnv, utf: *const c_char) -> jstring { + let message = CStr::from_ptr(utf).to_string_lossy().into_owned(); + fake(|f| f.last_string = message); + 0x70 as jstring + } + + unsafe extern "system" fn throw(_: *mut RawEnv, _: jthrowable) -> jint { + fake(|f| { + f.exception_pending = true; + let message = f.last_string.clone(); + f.thrown.push(message); + }); + 0 + } + + unsafe extern "system" fn delete_local_ref(_: *mut RawEnv, _: jobject) {} + + /// Run `body` against a fresh fake JVM. + fn with_fake_jvm(setup: Fake, body: impl FnOnce(JNIEnv<'static>) -> T) -> (T, Vec) { + fake(|f| *f = setup); + // SAFETY: an all-null table is valid, every entry is an `Option` or a + // raw pointer; a call to an entry left null is a jni `Err`, not UB. + let mut table: JNINativeInterface_ = unsafe { std::mem::zeroed() }; + table.GetArrayLength = Some(get_array_length); + table.GetByteArrayRegion = Some(get_byte_array_region); + table.GetIntArrayRegion = Some(get_int_array_region); + table.ExceptionCheck = Some(exception_check); + table.ExceptionClear = Some(exception_clear); + table.FindClass = Some(find_class); + table.GetMethodID = Some(get_method_id); + table.NewObjectA = Some(new_object_a); + table.NewStringUTF = Some(new_string_utf); + table.Throw = Some(throw); + table.DeleteLocalRef = Some(delete_local_ref); + let table_ptr: *const JNINativeInterface_ = &table; + let raw_env = &table_ptr as *const *const JNINativeInterface_ as *mut RawEnv; + // SAFETY: `table` and `table_ptr` outlive `body`, which is the only + // user of the env; the `'static` is the signature of the exports. + let env = unsafe { JNIEnv::from_raw(raw_env) }.expect("the env pointer is not null"); + let out = body(env); + (out, fake(|f| std::mem::take(&mut f.thrown))) + } + + fn key_ids_array() -> JIntArray<'static> { + // SAFETY: a fake object only the fake table interprets. + unsafe { JIntArray::from_raw(KEY_IDS_ARRAY as jintArray) } + } + + fn read(setup: Fake, array: JIntArray<'static>) -> (Option>, Vec) { + with_fake_jvm(setup, |mut env| read_key_ids(&mut env, &array)) + } + + /// One AUTHENTICATION key row, so an update that drops its disables would + /// still have something to submit. + fn one_key_blob() -> Vec { + let mut blob = 1u32.to_be_bytes().to_vec(); + blob.extend_from_slice(&9u32.to_be_bytes()); + blob.extend_from_slice(&[0, 0, 2, 0, 0]); + blob.extend_from_slice(&33u16.to_be_bytes()); + blob.extend_from_slice(&[2; 33]); + blob.push(0); + blob + } + + #[test] + fn should_read_the_key_ids() { + let setup = Fake { + key_ids: vec![1, 2], + ..Fake::default() + }; + assert_eq!(read(setup, key_ids_array()), (Some(vec![1, 2]), Vec::new())); + } + + #[test] + fn should_read_a_zero_length_array_as_no_keys() { + assert_eq!( + read(Fake::default(), key_ids_array()), + (Some(Vec::new()), Vec::new()) + ); + } + + #[test] + fn should_throw_on_a_negative_key_id() { + let setup = Fake { + key_ids: vec![-1], + ..Fake::default() + }; + assert_eq!( + read(setup, key_ids_array()), + (None, vec!["keyIds must be non-negative".to_string()]) + ); + } + + #[test] + fn should_throw_when_the_key_ids_cannot_be_read() { + let setup = Fake { + key_ids: vec![1, 2], + fail_key_ids_read: true, + ..Fake::default() + }; + assert_eq!( + read(setup, key_ids_array()), + (None, vec![READ_FAILED.to_string()]) + ); + } + + #[test] + fn should_throw_on_a_null_key_ids_array() { + // SAFETY: a null array is what a JNI caller can pass. + let null = unsafe { JIntArray::from_raw(ptr::null_mut()) }; + assert_eq!( + read(Fake::default(), null), + (None, vec![READ_FAILED.to_string()]) + ); + } + + #[test] + fn should_not_give_a_verdict_when_the_key_ids_cannot_be_read() { + let setup = Fake { + key_ids: vec![1], + fail_key_ids_read: true, + ..Fake::default() + }; + let (verdict, thrown) = with_fake_jvm(setup, |env| { + // SAFETY: fake objects only the fake table interprets. + let (class, id) = unsafe { + ( + JClass::from_raw(ptr::null_mut()), + JByteArray::from_raw(ID_ARRAY as jbyteArray), + ) + }; + Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_checkKeysCanBeDisabled( + env, + class, + 0, + id, + key_ids_array(), + ) + }); + assert!(verdict.is_null()); + assert_eq!(thrown, vec![READ_FAILED.to_string()]); + } + + #[test] + fn should_not_submit_an_update_when_its_disables_cannot_be_read() { + let setup = Fake { + blob: one_key_blob(), + key_ids: vec![1], + fail_key_ids_read: true, + ..Fake::default() + }; + let (_, thrown) = with_fake_jvm(setup, |env| { + // SAFETY: fake objects only the fake table interprets. + let (class, id, blob) = unsafe { + ( + JClass::from_raw(ptr::null_mut()), + JByteArray::from_raw(ID_ARRAY as jbyteArray), + JByteArray::from_raw(BLOB_ARRAY as jbyteArray), + ) + }; + Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_updateIdentity( + env, + class, + 0, + id, + blob, + key_ids_array(), + 0, + ) + }); + assert_eq!(thrown, vec![READ_FAILED.to_string()]); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 548e2d19c83..c5033888d47 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3782,6 +3782,85 @@ extension ManagedPlatformWallet { }.value } + /// Why the wallet refuses disabling a set of keys — mirror of + /// platform-wallet's `KeyDisableRefusal`. + public enum KeyDisableRefusal: Equatable, Sendable { + /// DIP-11: `securityLevel` would be left without a valid + /// authentication key. + case lastAuthenticationKey(keyId: UInt32, securityLevel: SecurityLevel) + /// Wallet policy: `purpose` (TRANSFER) would be left without an + /// enabled key able to sign. + case lastEnabledKey(keyId: UInt32, purpose: KeyPurpose) + + /// The key that cannot be disabled. + public var keyId: UInt32 { + switch self { + case .lastAuthenticationKey(let keyId, _), .lastEnabledKey(let keyId, _): + return keyId + } + } + } + + /// Whether the wallet lets an identity update that only disables + /// `keyIds` of `identityId` through — no security level loses its last + /// valid authentication key (DIP-11) and an enabled TRANSFER key able to + /// sign remains — judged by Rust against this wallet's identity with the + /// policy `updateIdentity` refuses with: `nil` when it does, otherwise + /// the refusal. No added key counts here, so `updateIdentity` can accept + /// a disable this refuses when the same update adds a replacement key. + /// Marshals `platform_wallet_check_keys_can_be_disabled`; throws when + /// the identity is not loaded in this wallet. + public func checkKeysCanBeDisabled( + identityId: Identifier, + keyIds: [UInt32] + ) async throws -> KeyDisableRefusal? { + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter("identityId must be 32 bytes") + } + let handle = self.handle + let idBytes: [UInt8] = identityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let ids = keyIds + return try await Task.detached(priority: .userInitiated) { + var check = KeyDisableCheckFFI() + let result = idBytes.withUnsafeBufferPointer { idBp in + ids.withUnsafeBufferPointer { idsBp in + platform_wallet_check_keys_can_be_disabled( + handle, + idBp.baseAddress!, + ids.isEmpty ? nil : idsBp.baseAddress, + UInt(ids.count), + &check + ) + } + } + try result.check() + switch check.refusal { + case 0: + return nil + case 1: + guard let level = SecurityLevel(rawValue: check.refused_security_level) else { + throw PlatformWalletError.invalidParameter( + "unknown security level \(check.refused_security_level) in a key-disable refusal" + ) + } + return .lastAuthenticationKey(keyId: check.refused_key_id, securityLevel: level) + case 2: + guard let purpose = KeyPurpose(rawValue: check.refused_purpose) else { + throw PlatformWalletError.invalidParameter( + "unknown key purpose \(check.refused_purpose) in a key-disable refusal" + ) + } + return .lastEnabledKey(keyId: check.refused_key_id, purpose: purpose) + default: + throw PlatformWalletError.invalidParameter( + "unknown key-disable refusal kind \(check.refusal)" + ) + } + }.value + } + /// Raise the usage limits of one of an identity's keys (protocol /// version 14), signing the resulting `IdentityKeyLimitsUpdate` with /// the identity's MASTER key (or a CRITICAL authentication key that diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift index 18ecc1aab74..26e497fbb5e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift @@ -3,25 +3,13 @@ import SwiftDashSDK /// Client-side safety gate for the **Disable Key** action. /// -/// This is a pre-flight UI guard, not protocol logic. It mixes two -/// kinds of refusal: -/// -/// - **Master key (consensus):** disabling a lone master key really is -/// rejected by consensus (`validate_master_key_uniqueness_v0`), so -/// refusing it here just avoids spending fees on a transition we -/// already know `rs-drive-abci` will reject. -/// - **Last auth / last transfer (client-side self-brick guards):** -/// these are *not* protocol invariants. The drive-abci -/// identity_update state validator only checks that every disabled -/// key id exists in state and that master-key uniqueness holds — it -/// would happily accept disabling the last enabled authentication or -/// transfer key. We refuse those locally purely as a UX safeguard so -/// the user can't strand (brick) their own identity. -/// -/// The gate is computed against the identity's full enabled key set -/// (these guards are only meaningful relative to what else remains -/// enabled), so callers pass `allKeys` (the identity's -/// `identityPublicKeys`) alongside the `target` key being disabled. +/// A pre-flight UI guard that decides nothing itself. Which keys may be +/// disabled is the wallet's call (`ManagedPlatformWallet.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. enum KeyDisableGate { /// Result of evaluating whether `target` may be disabled. enum Evaluation: Equatable { @@ -33,62 +21,72 @@ enum KeyDisableGate { case forbidden(reason: String) } - /// Evaluate the disable gate for `target` within `allKeys`. - /// - /// `allKeys` should be the identity's current `identityPublicKeys` - /// (the DPP projection). `target` must be one of them. + /// Rust's verdict for disabling the target key alone. + enum RuleVerdict: Equatable { + /// The wallet allows disabling the key. + case allowed + /// The wallet refuses it, for the reason the refusal names. + case refused(ManagedPlatformWallet.KeyDisableRefusal) + /// No verdict: the wallet is not loaded, or the check failed or is + /// still running. `reason` is user-facing copy. + case unavailable(reason: String) + } + + /// 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. + static func ruleVerdict( + wallet: ManagedPlatformWallet?, + identityId: Identifier, + keyId: UInt32 + ) async -> RuleVerdict { + guard let wallet else { + return .unavailable( + reason: "Identity not attached to a loaded wallet — cannot check or sign the disable transition." + ) + } + do { + if let refusal = try await wallet.checkKeysCanBeDisabled( + identityId: identityId, + keyIds: [keyId] + ) { + return .refused(refusal) + } + return .allowed + } catch { + return .unavailable(reason: error.localizedDescription) + } + } + + /// Evaluate the disable gate for `target`, given Rust's `ruleVerdict` + /// for disabling it alone. static func evaluate( target: IdentityPublicKey, - allKeys: [IdentityPublicKey] + ruleVerdict: RuleVerdict ) -> Evaluation { // Already disabled → nothing to do. if target.disabledAt != nil { return .alreadyDisabled } - // A lone master-key disable is rejected by consensus - // (`validate_master_key_uniqueness_v0`); master-key rotation - // is out of scope for this action. - if target.securityLevel == .master { - return .forbidden( - reason: "Master keys can't be disabled here — a master-key disable is rejected by consensus. Master-key rotation is out of scope." - ) + switch ruleVerdict { + case .allowed: + return .allowed + case .unavailable(let reason): + return .forbidden(reason: reason) + case .refused(let refusal): + return .forbidden(reason: reason(for: refusal)) } - - // Client-side self-brick guard (not a consensus rule): - // drive-abci would accept this transition, but disabling the - // last enabled AUTHENTICATION key would leave the identity - // unable to authenticate / sign future transitions, so we - // refuse it locally to keep the user from stranding the - // identity. - if target.purpose == .authentication - && enabledCount(of: .authentication, in: allKeys) <= 1 { - return .forbidden( - reason: "This is the only enabled authentication key. Disabling it would leave the identity unable to sign — add another authentication key first (Add Key)." - ) - } - - // Client-side self-brick guard (not a consensus rule): - // drive-abci would accept this too, but disabling the last - // enabled TRANSFER key would break credit withdrawals / - // transfers (ID-10), and re-adding a transfer key currently - // requires the Add Key flow (ID-07). We refuse it locally as a - // UX safeguard. - if target.purpose == .transfer - && enabledCount(of: .transfer, in: allKeys) <= 1 { - return .forbidden( - reason: "This is the only enabled transfer key. Disabling it would break credit withdrawals — add another transfer key first (Add Key)." - ) - } - - return .allowed } - /// Count of enabled (non-disabled) keys with the given purpose. - private static func enabledCount( - of purpose: KeyPurpose, - in keys: [IdentityPublicKey] - ) -> Int { - keys.filter { $0.purpose == purpose && $0.disabledAt == nil }.count + /// User-facing copy for a refusal. + private static func reason(for refusal: ManagedPlatformWallet.KeyDisableRefusal) -> String { + switch refusal { + case .lastAuthenticationKey(_, let securityLevel): + return "No other valid authentication key at the \(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)." + case .lastEnabledKey(_, let purpose): + let name = purpose.name.lowercased() + return "No other enabled \(name) key able to sign would remain. Disabling this key would break credit withdrawals and transfers — add another \(name) key first (Add Key)." + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift index e370a849424..24e13a20273 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift @@ -13,6 +13,10 @@ struct KeyDetailView: View { @State private var showDisableConfirm = false @State private var isDisabling = false @State private var disableError: String? + /// The wallet's verdict for disabling this key, refreshed whenever the + /// identity's key set or the wallet's presence changes. + @State private var ruleVerdict: KeyDisableGate.RuleVerdict = + .unavailable(reason: "Checking whether this key may be disabled…") /// What Platform says is left of this key's budget. `.some(nil)` means /// the node answered that the key carries no budget. @State private var remainingBudget: UInt64?? = nil @@ -29,16 +33,27 @@ struct KeyDetailView: View { return result } - /// Pre-flight gate for disabling this key. Evaluated against the - /// identity's current key set so the result reflects "is this the - /// last enabled auth/transfer key" relative to everything else. + /// Pre-flight gate for disabling this key: the wallet's verdict from + /// Rust (DIP-11 and the last transfer key). private var disableEvaluation: KeyDisableGate.Evaluation { KeyDisableGate.evaluate( target: publicKey, - allKeys: identity.identityPublicKeys + ruleVerdict: ruleVerdict ) } + /// The loaded wallet this identity belongs to, if any. + private var identityWallet: ManagedPlatformWallet? { + (identity.wallet?.walletId).flatMap { walletManager.wallet(for: $0) } + } + + /// Changes whenever a key is added or disabled or the wallet finishes + /// loading, so the verdict is re-asked. + private var ruleVerdictInputs: [String] { + identity.identityPublicKeys.map { "\($0.id):\(String(describing: $0.disabledAt))" } + + ["walletLoaded:\(identityWallet != nil)"] + } + var body: some View { Form { // Key Information Section @@ -140,6 +155,17 @@ struct KeyDetailView: View { } .navigationTitle("Key #\(publicKey.id)") .navigationBarTitleDisplayMode(.inline) + .task(id: ruleVerdictInputs) { + let verdict = await KeyDisableGate.ruleVerdict( + wallet: identityWallet, + identityId: identity.identityId, + keyId: publicKey.id + ) + // A newer key set or wallet superseded this check; don't let its + // answer clobber the fresher one. + guard !Task.isCancelled else { return } + ruleVerdict = verdict + } .alert("Success", isPresented: $showSuccessAlert) { Button("OK") { dismiss() @@ -398,9 +424,15 @@ struct KeyDetailView: View { /// appears) and pop back to the key list. @MainActor private func disableKey() async { - // Re-check the gate at submit time — the key set could have - // changed since the view rendered (a background sync, a - // sibling disable). Never broadcast a doomed transition. + // Re-check the gate at submit time, with a fresh verdict — the + // key set could have changed since the view rendered (a + // background sync, a sibling disable). Never broadcast a doomed + // transition. + ruleVerdict = await KeyDisableGate.ruleVerdict( + wallet: identityWallet, + identityId: identity.identityId, + keyId: publicKey.id + ) guard case .allowed = disableEvaluation else { return } guard let walletId = identity.wallet?.walletId else { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift index a2e2eed5ff4..a0b7b061721 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift @@ -20,6 +20,10 @@ struct KeysListView: View { @State private var pendingDisableKey: IdentityPublicKey? @State private var isDisabling = false @State private var disableError: String? + /// The wallet's verdict for disabling each enabled key alone, refreshed + /// whenever the key set or the wallet's presence changes. Absent while + /// loading. + @State private var ruleVerdicts: [UInt32: KeyDisableGate.RuleVerdict] = [:] /// What Platform says is left of each budgeted key, fetched once for the /// whole list. Absent while loading or when the query failed. @State private var remainingBudgets: [UInt32: UInt64?] = [:] @@ -28,6 +32,23 @@ struct KeysListView: View { identity.identityPublicKeys } + /// The loaded wallet this identity belongs to, if any. + private var identityWallet: ManagedPlatformWallet? { + (identity.wallet?.walletId).flatMap { walletManager.wallet(for: $0) } + } + + /// Changes whenever a key is added or disabled or the wallet finishes + /// loading, so the verdicts are re-asked. + private var ruleVerdictInputs: [String] { + publicKeys.map { "\($0.id):\(String(describing: $0.disabledAt))" } + + ["walletLoaded:\(identityWallet != nil)"] + } + + private func ruleVerdict(for publicKey: IdentityPublicKey) -> KeyDisableGate.RuleVerdict { + ruleVerdicts[publicKey.id] + ?? .unavailable(reason: "Checking whether this key may be disabled…") + } + private var privateKeysAvailableCount: Int { publicKeys.filter { publicKey in hasPrivateKey(for: publicKey) @@ -110,6 +131,20 @@ struct KeysListView: View { .task(id: publicKeys.compactMap { $0.totalBudget != nil ? $0.id : nil }) { await loadRemainingBudgets() } + .task(id: ruleVerdictInputs) { + var verdicts: [UInt32: KeyDisableGate.RuleVerdict] = [:] + for publicKey in publicKeys where publicKey.disabledAt == nil { + verdicts[publicKey.id] = await KeyDisableGate.ruleVerdict( + wallet: identityWallet, + identityId: identity.identityId, + keyId: publicKey.id + ) + } + // A newer key set or wallet superseded this check; don't let its answer + // clobber the fresher one. + guard !Task.isCancelled else { return } + ruleVerdicts = verdicts + } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { @@ -181,7 +216,7 @@ struct KeysListView: View { private func disableSwipeButton(for publicKey: IdentityPublicKey) -> some View { if case .allowed = KeyDisableGate.evaluate( target: publicKey, - allKeys: publicKeys + ruleVerdict: ruleVerdict(for: publicKey) ) { Button(role: .destructive) { pendingDisableKey = publicKey @@ -200,12 +235,24 @@ struct KeysListView: View { private func disableKey(_ publicKey: IdentityPublicKey) async { pendingDisableKey = nil - // Re-check the gate at submit time — the key set could have - // changed since the swipe (background sync, a sibling disable). - guard case .allowed = KeyDisableGate.evaluate( - target: publicKey, - allKeys: publicKeys - ) else { return } + // Re-check the gate at submit time, with a fresh verdict — the key + // set could have changed since the swipe (background sync, a sibling + // disable). + let verdict = await KeyDisableGate.ruleVerdict( + wallet: identityWallet, + identityId: identity.identityId, + keyId: publicKey.id + ) + ruleVerdicts[publicKey.id] = verdict + switch KeyDisableGate.evaluate(target: publicKey, ruleVerdict: verdict) { + case .allowed: + break + case .alreadyDisabled: + return + case .forbidden(let reason): + disableError = reason + return + } guard let walletId = identity.wallet?.walletId else { disableError = "Identity has no wallet linkage; cannot sign the disable transition." diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift index 91f5318cf69..16a2689401d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift @@ -2,9 +2,10 @@ // KeyDisableGateTests.swift // SwiftExampleAppTests // -// Unit coverage for `KeyDisableGate.evaluate` — the pure, single -// source of truth for whether a Disable Key submit is attempted -// (used in KeyDetailView, KeysListView, and the pre-submit re-check). +// Unit coverage for `KeyDisableGate.evaluate` — what a Disable Key +// action shows for the wallet's verdict (used in KeyDetailView, +// KeysListView, and the pre-submit re-check). The verdict itself comes +// from Rust (DIP-11 and the last transfer key) and is covered there. // import XCTest @@ -16,9 +17,7 @@ final class KeyDisableGateTests: XCTestCase { // MARK: - Fixtures /// Build an `IdentityPublicKey` test value with the fields the gate - /// actually reads (`id`, `purpose`, `securityLevel`, `disabledAt`). - /// `keyType`/`data`/`readOnly` are irrelevant to the gate, so they - /// get trivial defaults. + /// actually reads (`disabledAt`). The rest get trivial defaults. private func makeKey( id: KeyID, purpose: KeyPurpose, @@ -44,161 +43,62 @@ final class KeyDisableGateTests: XCTestCase { func testAlreadyDisabledTarget_returnsAlreadyDisabled() { let target = makeKey(id: 5, purpose: .authentication, disabledAt: disabledTimestamp) - // Include a second enabled auth key so the result can only be - // `.alreadyDisabled` (and not, e.g., a last-auth refusal). - let other = makeKey(id: 6, purpose: .authentication) - - let result = KeyDisableGate.evaluate(target: target, allKeys: [target, other]) - - XCTAssertEqual(result, .alreadyDisabled) - } - - // MARK: - 2. Master key - - func testMasterKey_isForbidden() { - // A master key is also an authentication key in practice; the - // master check runs before the auth check, so this must be - // `.forbidden` regardless of how many other auth keys exist. - let target = makeKey(id: 0, purpose: .authentication, securityLevel: .master) - let other = makeKey(id: 1, purpose: .authentication) - - let result = KeyDisableGate.evaluate(target: target, allKeys: [target, other]) - - guard case .forbidden = result else { - return XCTFail("expected .forbidden for a master key, got \(result)") - } - } - - // MARK: - 3. Last authentication key - - func testLastEnabledAuthenticationKey_isForbidden() { - let target = makeKey(id: 1, purpose: .authentication) - - let result = KeyDisableGate.evaluate(target: target, allKeys: [target]) - - guard case .forbidden = result else { - return XCTFail("expected .forbidden for the only auth key, got \(result)") - } - } - - func testAuthenticationKey_withSecondEnabledAuth_isAllowed() { - let target = makeKey(id: 1, purpose: .authentication) - let second = makeKey(id: 2, purpose: .authentication) - - let result = KeyDisableGate.evaluate(target: target, allKeys: [target, second]) - - XCTAssertEqual(result, .allowed) - } - - // MARK: - 4. Last transfer key - - func testLastEnabledTransferKey_isForbidden() { - // Pair with an auth key so the auth rail can't trip first and - // the transfer rail is the one under test. - let target = makeKey(id: 3, purpose: .transfer) - let auth = makeKey(id: 1, purpose: .authentication) - - let result = KeyDisableGate.evaluate(target: target, allKeys: [target, auth]) - - guard case .forbidden = result else { - return XCTFail("expected .forbidden for the only transfer key, got \(result)") - } - } - - func testTransferKey_withSecondEnabledTransfer_isAllowed() { - let target = makeKey(id: 3, purpose: .transfer) - let secondTransfer = makeKey(id: 4, purpose: .transfer) - let auth = makeKey(id: 1, purpose: .authentication) + // Whatever Rust says, a disabled key has nothing left to do. let result = KeyDisableGate.evaluate( target: target, - allKeys: [target, secondTransfer, auth] + ruleVerdict: .refused(.lastAuthenticationKey(keyId: 5, securityLevel: .high)) ) - XCTAssertEqual(result, .allowed) + XCTAssertEqual(result, .alreadyDisabled) } - // MARK: - 5. Normal non-last key + // MARK: - 2. The wallet's verdict - func testNormalNonLastKey_isAllowed() { - // A non-auth / non-transfer purpose with no special rails. - let target = makeKey(id: 7, purpose: .encryption) - // Keep an auth key around so the identity stays signable; this - // shouldn't matter for an encryption-key disable, but it makes - // the scenario realistic. - let auth = makeKey(id: 1, purpose: .authentication) - - let result = KeyDisableGate.evaluate(target: target, allKeys: [target, auth]) + func testAllowedVerdict_isAllowed() { + // Which keys may go is Rust's call: with another MASTER key, even a + // MASTER key may be disabled. + let target = makeKey(id: 0, purpose: .authentication, securityLevel: .master) - XCTAssertEqual(result, .allowed) + XCTAssertEqual(KeyDisableGate.evaluate(target: target, ruleVerdict: .allowed), .allowed) } - // MARK: - 6. enabledCount edge cases (exercised through evaluate) - - /// Keys of a *different* purpose must not count toward the auth - /// limit: disabling the only auth key is forbidden even when other - /// purposes have many enabled keys. - func testOtherPurposeKeysDoNotCountTowardAuthLimit() { - let target = makeKey(id: 1, purpose: .authentication) - let encryption = makeKey(id: 2, purpose: .encryption) - let transferA = makeKey(id: 3, purpose: .transfer) - let transferB = makeKey(id: 4, purpose: .transfer) + func testLastAuthenticationKeyRefusal_isForbiddenNamingTheLevel() { + let target = makeKey(id: 1, purpose: .authentication, securityLevel: .critical) let result = KeyDisableGate.evaluate( target: target, - allKeys: [target, encryption, transferA, transferB] + ruleVerdict: .refused(.lastAuthenticationKey(keyId: 1, securityLevel: .critical)) ) - guard case .forbidden = result else { - return XCTFail( - "expected .forbidden — other-purpose keys must not satisfy the auth requirement, got \(result)" - ) + guard case .forbidden(let reason) = result else { + return XCTFail("expected .forbidden for a DIP-11 refusal, got \(result)") } + XCTAssertTrue(reason.contains(SecurityLevel.critical.name), reason) } - /// Already-disabled keys of the same purpose are excluded from the - /// count: two auth keys where one is already disabled still forbids - /// disabling the remaining enabled one. - func testAlreadyDisabledSamePurposeKeyExcludedFromCount() { - let target = makeKey(id: 1, purpose: .authentication) - let disabledAuth = makeKey( - id: 2, - purpose: .authentication, - disabledAt: disabledTimestamp - ) + func testLastEnabledKeyRefusal_isForbiddenNamingThePurpose() { + let target = makeKey(id: 3, purpose: .transfer) let result = KeyDisableGate.evaluate( target: target, - allKeys: [target, disabledAuth] + ruleVerdict: .refused(.lastEnabledKey(keyId: 3, purpose: .transfer)) ) - guard case .forbidden = result else { - return XCTFail( - "expected .forbidden — a disabled sibling auth key is not an enabled fallback, got \(result)" - ) + guard case .forbidden(let reason) = result else { + return XCTFail("expected .forbidden for the last transfer key, got \(result)") } + XCTAssertTrue(reason.contains("transfer"), reason) } - /// Symmetric transfer-side check: a disabled sibling transfer key - /// doesn't keep the last enabled transfer key disableable. - func testAlreadyDisabledSiblingTransferKeyExcludedFromCount() { - let target = makeKey(id: 3, purpose: .transfer) - let disabledTransfer = makeKey( - id: 4, - purpose: .transfer, - disabledAt: disabledTimestamp - ) - let auth = makeKey(id: 1, purpose: .authentication) + func testUnavailableVerdict_isForbiddenWithItsReason() { + let target = makeKey(id: 1, purpose: .authentication) let result = KeyDisableGate.evaluate( target: target, - allKeys: [target, disabledTransfer, auth] + ruleVerdict: .unavailable(reason: "Wallet not loaded") ) - guard case .forbidden = result else { - return XCTFail( - "expected .forbidden — a disabled sibling transfer key is not an enabled fallback, got \(result)" - ) - } + XCTAssertEqual(result, .forbidden(reason: "Wallet not loaded")) } }