diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt index 40c032b1b9f..252a5b0fa2a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/SignerNative.kt @@ -49,6 +49,14 @@ internal object SignerNative { */ external fun signWithPrivateKey(privateKey: ByteArray, network: Int, data: ByteArray): ByteArray? + /** + * Whether Rust signs an identity key of DPP `KeyType` discriminant + * [keyType] (`dash_sdk_resolver_supports_key_type`, the predicate + * `KeychainSigner.swift` asks). Pure; a value outside `0..=255` is + * `false`. + */ + external fun resolverSupportsKeyType(keyType: Int): Boolean + /** * One-shot derive-then-sign for platform-address keys (the * `keyType == 0xFF` branch). Derives an ECDSA secp256k1 key from diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt index 5a2fbb82631..27c37c8571d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/KeystoreSigner.kt @@ -58,6 +58,12 @@ class KeystoreSigner( * Room rows and re-seed `pendingIdentityKeys`. */ private val onSigningKeyInvalidated: (suspend (pubkeyHex: String) -> Unit)? = null, + /** + * Whether Rust signs an identity key of a DPP `KeyType` discriminant. + * Rust owns that set; the parameter exists so a JVM unit test can stub + * it without the native library. + */ + private val supportsIdentityKeyType: (Int) -> Boolean = SignerNative::resolverSupportsKeyType, ) : NativeSignerBridge(), AutoCloseable { private val handleRef = @@ -303,21 +309,23 @@ class KeystoreSigner( } override fun canSignWith(pubkeyBytes: ByteArray, keyType: Int): Boolean = try { - if (keyType == PLATFORM_ADDRESS_HASH_KEY_TYPE) { - // Prerequisites for the derive-and-sign path: a row with a - // non-empty derivation path plus a stored wallet mnemonic. - // Existence-only — hasMnemonic never decrypts, so no plaintext - // is materialized for a mere capability check. - runBlocking { - platformAddressDao.getAllByAddressHash(pubkeyBytes).any { row -> - row.derivationPath.isNotEmpty() && storage.hasMnemonic(row.walletId) + when { + keyType == PLATFORM_ADDRESS_HASH_KEY_TYPE -> + // Prerequisites for the derive-and-sign path: a row with a + // non-empty derivation path plus a stored wallet mnemonic. + // Existence-only — hasMnemonic never decrypts, so no plaintext + // is materialized for a mere capability check. + runBlocking { + platformAddressDao.getAllByAddressHash(pubkeyBytes).any { row -> + row.derivationPath.isNotEmpty() && storage.hasMnemonic(row.walletId) + } } + else -> identityKeySignable(keyType, supportsIdentityKeyType) { + // Ciphertext presence is not signing capability after Android + // replaces KEYS_ALIAS: the stale blob remains in DataStore but + // can never be decrypted by the replacement private key. + runBlocking { storage.isPrivateKeyDecryptable(storageKeyFor(pubkeyBytes)) } } - } else { - // Ciphertext presence is not signing capability after Android - // replaces KEYS_ALIAS: the stale blob remains in DataStore but - // can never be decrypted by the replacement private key. - runBlocking { storage.isPrivateKeyDecryptable(storageKeyFor(pubkeyBytes)) } } } catch (_: Exception) { false @@ -372,6 +380,21 @@ class KeystoreSigner( */ private const val PLATFORM_ADDRESS_HASH_KEY_TYPE: Int = 0xFF + /** + * Whether [canSignWith] reports an identity key of DPP `KeyType` + * discriminant [keyType] signable. Rust picks signing keys by this + * answer: claiming a key the secp256k1 sign path cannot sign (BLS, + * EdDSA) would get it chosen and then signed with the wrong + * algorithm, so [supportsKeyType] (Rust's predicate) is asked first + * and [privateKeyDecryptable] only for a type it accepts. Factored + * pure so it is unit-testable without the native signer handle. + */ + internal fun identityKeySignable( + keyType: Int, + supportsKeyType: (Int) -> Boolean, + privateKeyDecryptable: () -> Boolean, + ): Boolean = supportsKeyType(keyType) && privateKeyDecryptable() + /** * Structured completion code for a sign-path failure [t]: * [KeyPermanentlyInvalidatedException] is a "key unavailable until diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerKeyTypeTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerKeyTypeTest.kt new file mode 100644 index 00000000000..147bb26c093 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/KeystoreSignerKeyTypeTest.kt @@ -0,0 +1,50 @@ +package org.dashfoundation.dashsdk.security + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins how [KeystoreSigner.canSignWith] answers for an identity key. Rust + * chooses the signing key by that answer, so a key type Rust's predicate + * refuses (BLS, EdDSA) must not be reported signable even with a stored + * private key. Which types Rust signs is pinned by the Rust tests of + * `dash_sdk_resolver_supports_key_type`; this JVM test cannot load the + * native library, so it stubs the predicate. + */ +class KeystoreSignerKeyTypeTest { + + @Test + fun refusesAKeyTypeRustDoesNotSignWithoutConsultingStorage() { + var storageConsulted = false + val signable = KeystoreSigner.identityKeySignable( + keyType = 1, + supportsKeyType = { false }, + ) { + storageConsulted = true + true + } + assertFalse(signable) + assertFalse(storageConsulted) + } + + @Test + fun asksRustAboutTheKeyTypeItWasGiven() { + var askedFor: Int? = null + KeystoreSigner.identityKeySignable( + keyType = 2, + supportsKeyType = { + askedFor = it + true + }, + ) { true } + assertEquals(2, askedFor) + } + + @Test + fun reportsAKeyTypeRustSignsSignableOnlyWhenItsKeyDecrypts() { + assertTrue(KeystoreSigner.identityKeySignable(0, { true }) { true }) + assertFalse(KeystoreSigner.identityKeySignable(0, { true }) { false }) + } +} diff --git a/packages/rs-dpp/src/identity/identity_public_key/key_type.rs b/packages/rs-dpp/src/identity/identity_public_key/key_type.rs index 02df74d6347..ca0333e83f9 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/key_type.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/key_type.rs @@ -110,6 +110,22 @@ impl KeyType { } } + /// Can a key of this type sign an identity-signed state transition? + /// + /// This is the set Drive's identity-signed validation verifies a signature by; a key of + /// any other type is refused there with `InvalidIdentityPublicKeyTypeError`. Clients use + /// it to choose or check a signing key before building a transition. A Drive test pins the + /// two to agree for every key type. + pub fn can_sign_identity_signed_transition(&self) -> bool { + match self { + KeyType::ECDSA_SECP256K1 => true, + KeyType::BLS12_381 => true, + KeyType::ECDSA_HASH160 => true, + KeyType::BIP13_SCRIPT_HASH => false, + KeyType::EDDSA_25519_HASH160 => false, + } + } + pub fn signature_verify_cost( &self, platform_version: &PlatformVersion, @@ -413,6 +429,24 @@ mod tests { ); } + // -- can_sign_identity_signed_transition() -- + + #[test] + fn should_sign_identity_signed_transitions_only_with_ecdsa_bls_and_hash160_keys() { + let signing: Vec = KeyType::all_key_types() + .into_iter() + .filter(KeyType::can_sign_identity_signed_transition) + .collect(); + assert_eq!( + signing, + [ + KeyType::ECDSA_SECP256K1, + KeyType::BLS12_381, + KeyType::ECDSA_HASH160 + ] + ); + } + // -- is_unique_key_type() -- #[test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v0/mod.rs index ebe8dd92c26..2bafcfe4a79 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v0/mod.rs @@ -269,6 +269,19 @@ mod tests { use dpp::state_transition::errors::PublicKeySecurityLevelNotMetError; use dpp::state_transition::errors::WrongPublicKeyPurposeError; + /// Clients choose and check signing keys by the dpp predicate; it must name exactly the key + /// types this validation verifies a signature by. + #[test] + fn should_agree_with_the_dpp_identity_signing_key_type_predicate_for_every_key_type() { + for key_type in KeyType::all_key_types() { + assert_eq!( + key_type.can_sign_identity_signed_transition(), + SUPPORTED_KEY_TYPES.contains(&key_type), + "{key_type:?}" + ); + } + } + mod convert_to_consensus_signature_error_tests { use super::*; diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index e3a13d0e237..936a4a0d797 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -1396,7 +1396,6 @@ mod tests { identity_id, purpose: dpp::identity::Purpose::AUTHENTICATION, security_levels: vec![dpp::identity::SecurityLevel::CRITICAL], - key_types: vec![dpp::identity::KeyType::ECDSA_SECP256K1], transition: "an identity update".to_string(), }); assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorUnknown); @@ -1409,7 +1408,6 @@ mod tests { identity.as_str(), "AUTHENTICATION", "CRITICAL", - "ECDSA_SECP256K1", "an identity update", ] { assert!(message.contains(part), "{message}"); diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 79d157d576f..e6507d727f0 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -7,7 +7,7 @@ use dpp::consensus::state::identity::invalid_identity_revision_error::InvalidIde use dpp::consensus::ConsensusError; use dpp::fee::Credits; use dpp::identifier::Identifier; -use dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dpp::identity::{Purpose, SecurityLevel}; use dpp::prelude::{AddressNonce, CoreBlockHeight}; use key_wallet::account::StandardAccountType; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; @@ -105,19 +105,18 @@ pub enum PlatformWalletError { InvalidIdentityData(String), /// No key of the identity can sign `transition`: none has `purpose`, one of - /// `security_levels` and one of `key_types` while also being enabled, unexpired and inside - /// its contract bounds. + /// `security_levels` and a type Platform verifies an identity signature by while also being + /// enabled, unexpired and inside its contract bounds. /// It stays so until an identity update adds such a key. A key that qualifies but that the /// signer cannot use is the signer-unavailable error instead. #[error( - "identity {identity_id} has no usable {purpose} key of type {key_types:?} at security \ - level {security_levels:?} to sign {transition}" + "identity {identity_id} has no usable {purpose} key at security level \ + {security_levels:?} to sign {transition}" )] NoUsableSigningKey { identity_id: Identifier, purpose: Purpose, security_levels: Vec, - key_types: Vec, transition: String, }, diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 40b33328427..df162b24975 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -77,7 +77,7 @@ pub use wallet::identity::network::{ check_signing_key, derive_identity_auth_keypair, usable_signing_key, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoPublishOutcome, ContactInfoSealed, PreparedDashPayPayment, SeedBindingVerification, SigningScope, IDENTITY_GAP_LIMIT, - MASTER_KEY_INDEX, PLATFORM_SIGNING_KEY_TYPES, WALLET_SIGNING_KEY_TYPES, + MASTER_KEY_INDEX, }; pub use wallet::identity::{ calculate_account_reference, derive_auto_accept_private_key, derive_contact_payment_address, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs index 851b94291ef..c31900ed85d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs @@ -18,7 +18,7 @@ use dpp::document::{Document, DocumentV0}; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; -use dpp::identity::{IdentityPublicKey, KeyType, SecurityLevel}; +use dpp::identity::{Identity, IdentityPublicKey, SecurityLevel}; use dpp::platform_value::Value; use dpp::prelude::Identifier; @@ -30,6 +30,26 @@ use crate::wallet::identity::crypto::contact_info::{ ContactInfoPrivateData, }; +// contactInfo documents require HIGH or CRITICAL authentication; MASTER is +// reserved for identity operations and cannot authorize an ordinary document +// write. Key selection follows the profile writer: any key type Platform verifies +// an identity signature by (HASH160 keys included, after raw-public-key ones), +// skipping keys bound elsewhere, disabled or expired; `Err` when eligible keys +// exist but `signer` can reach none of them. +fn contact_info_signing_key<'a>( + identity: &'a Identity, + signer: &impl Signer, + dashpay_contract_id: Identifier, +) -> Result, dash_sdk::Error> { + super::usable_authentication_key( + identity, + signer, + dashpay_contract_id, + "contactInfo", + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + ) +} + /// One decrypted `contactInfo` document — the fields the sweep applies. (The /// publish path's update-vs-create needs `doc_id`/`revision`/`derivation_index` /// too; it reads those from [`RawContactInfoDoc`] before decrypting.) @@ -597,22 +617,15 @@ impl DashPayView<'_, B> { ); return Ok(ContactInfoPublishOutcome::SkippedWatchOnly); }; - let signing_key = super::usable_authentication_key( - &identity, - signer, - dashpay_contract.id(), - "contactInfo", - &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], - &[KeyType::ECDSA_SECP256K1], - )? - .cloned() - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "No HIGH or CRITICAL authentication key found on identity \ + let signing_key = contact_info_signing_key(&identity, signer, dashpay_contract.id())? + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "No HIGH or CRITICAL authentication key found on identity \ (required for document state transitions)" - .to_string(), - ) - })?; + .to_string(), + ) + })?; let root_key_id = root_key_id.ok_or_else(|| { PlatformWalletError::InvalidIdentityData( "Identity has no ECDSA_SECP256K1 encryption key (required for contactInfo)" @@ -795,3 +808,78 @@ mod pending_enqueue_tests { assert_eq!(backend.writes.load(std::sync::atomic::Ordering::SeqCst), 1); } } + +#[cfg(test)] +mod signing_key_tests { + use super::super::key_selection::tests::ecdsa_only_signer; + use super::super::signing_key::tests::KeyFilter; + use super::*; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::identity_public_key::Purpose; + use dpp::identity::KeyType; + use dpp::version::PlatformVersion; + + const DASHPAY: [u8; 32] = [0xDA; 32]; + + fn auth_key(id: u32, key_type: KeyType, security_level: SecurityLevel) -> IdentityPublicKey { + IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level, + key_type, + data: vec![1; key_type.default_size()].into(), + ..Default::default() + } + .into() + } + + fn identity_with(keys: Vec) -> Identity { + let mut identity = Identity::default_versioned(PlatformVersion::latest()).unwrap(); + identity.add_public_keys(keys); + identity + } + + fn pick(identity: &Identity) -> Option<&IdentityPublicKey> { + contact_info_signing_key(identity, &KeyFilter(|_| true), Identifier::from(DASHPAY)).unwrap() + } + + #[test] + fn should_select_high_and_critical_ecdsa_contact_info_keys() { + for key_type in [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160] { + for level in [SecurityLevel::HIGH, SecurityLevel::CRITICAL] { + let identity = identity_with(vec![auth_key(1, key_type, level)]); + assert!(pick(&identity).is_some(), "{key_type:?}/{level:?}"); + } + } + } + + #[test] + fn should_reject_ineligible_contact_info_keys() { + for key_type in [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160] { + for level in [SecurityLevel::MASTER, SecurityLevel::MEDIUM] { + assert!(pick(&identity_with(vec![auth_key(1, key_type, level)])).is_none()); + } + } + for key_type in [KeyType::BIP13_SCRIPT_HASH, KeyType::EDDSA_25519_HASH160] { + let identity = identity_with(vec![auth_key(1, key_type, SecurityLevel::HIGH)]); + assert!(pick(&identity).is_none(), "{key_type:?}"); + } + let bls = identity_with(vec![auth_key(1, KeyType::BLS12_381, SecurityLevel::HIGH)]); + assert!( + contact_info_signing_key(&bls, &ecdsa_only_signer(), Identifier::from(DASHPAY)) + .is_err(), + "a signer that cannot sign with a BLS key is never handed one" + ); + } + + #[test] + fn should_select_high_hash160_from_all_hash160_identity_layout() { + let identity = identity_with(vec![ + auth_key(0, KeyType::ECDSA_HASH160, SecurityLevel::MASTER), + auth_key(1, KeyType::ECDSA_HASH160, SecurityLevel::HIGH), + auth_key(2, KeyType::ECDSA_HASH160, SecurityLevel::CRITICAL), + ]); + assert_eq!(pick(&identity), identity.public_keys().get(&1)); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 84b48d6a934..cf1536874dc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -1,7 +1,7 @@ //! DashPay contact request lifecycle: send, sync, accept, reject. use super::document::document_signing_key; -use super::key_selection::{SigningScope, WALLET_SIGNING_KEY_TYPES}; +use super::key_selection::SigningScope; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentTypeRef; use dpp::document::DocumentV0Getters; @@ -634,7 +634,6 @@ impl DashPayView<'_, B> { security_levels: SecurityLevel::accepted_for_requirement( prepared.signing_security_level_requirement(), ), - key_types: WALLET_SIGNING_KEY_TYPES.to_vec(), transition: "a contactRequest document transition".to_string(), })?; @@ -1234,7 +1233,7 @@ fn select_recipient_key_index(recipient_identity: &Identity) -> Result DataContract { + load_system_data_contract( + SystemDataContract::Dashpay, + dpp::version::PlatformVersion::latest(), + ) + .expect("bundled DashPay contract") + } + + fn typed_key( + id: u32, + purpose: Purpose, + key_type: KeyType, + security_level: SecurityLevel, + ) -> IdentityPublicKey { + IdentityPublicKeyV0 { + id, + purpose, + security_level, + key_type, + data: vec![1; key_type.default_size()].into(), + ..Default::default() + } + .into() + } + + /// The key [`contact_request_signing_key`] picks from `identity` for the bundled + /// DashPay `contactRequest` type, with a signer that can sign with every key. + fn pick_for_contact_request(identity: &Identity) -> Option { + let contract = dashpay(); + let contact_request = contract + .document_type_for_name("contactRequest") + .expect("the contactRequest type"); + contact_request_signing_key(identity, &KeyFilter(|_| true), contact_request) + .unwrap() + .map(|key| key.id()) + } + + #[test] + fn should_select_high_and_critical_ecdsa_contact_request_keys() { + for key_type in [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160] { + for level in [SecurityLevel::HIGH, SecurityLevel::CRITICAL] { + let identity = + identity(vec![typed_key(1, Purpose::AUTHENTICATION, key_type, level)]); + assert_eq!( + pick_for_contact_request(&identity), + Some(1), + "{key_type:?}/{level:?}" + ); + } + } + } + + #[test] + fn should_select_high_hash160_over_master_transfer_and_encryption_keys() { + let identity = identity(vec![ + typed_key( + 0, + Purpose::AUTHENTICATION, + KeyType::ECDSA_HASH160, + SecurityLevel::MASTER, + ), + typed_key( + 1, + Purpose::TRANSFER, + KeyType::ECDSA_HASH160, + SecurityLevel::CRITICAL, + ), + typed_key( + 2, + Purpose::ENCRYPTION, + KeyType::ECDSA_SECP256K1, + SecurityLevel::MEDIUM, + ), + typed_key( + 3, + Purpose::AUTHENTICATION, + KeyType::ECDSA_HASH160, + SecurityLevel::HIGH, + ), + ]); + assert_eq!(pick_for_contact_request(&identity), Some(3)); + } + #[test] fn should_sign_a_contact_request_with_a_key_bound_to_contact_requests_or_unbound() { let dashpay = SystemDataContract::Dashpay.id(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contract.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contract.rs index edc2d99da07..5590e4af214 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contract.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contract.rs @@ -26,7 +26,6 @@ //! the rs-sdk-ffi path — classic stack-guard fingerprint, not //! memory unsafety. -use super::key_selection::WALLET_SIGNING_KEY_TYPES; use super::signing_key::{ contract_signing_key, CONTRACT_CREATE_SIGNING_LEVELS, CONTRACT_UPDATE_SIGNING_LEVELS, }; @@ -98,7 +97,7 @@ impl IdentityWallet { /// beyond `documents_schema_json` is optional and absent when /// `None`. The function: /// 1. Looks up `owner_identity_id` in the in-memory wallet - /// manager and picks an AUTHENTICATION ECDSA key at HIGH or + /// manager and picks an AUTHENTICATION key at HIGH or /// stronger short of MASTER, the levels DPP accepts for a /// contract-create signature (HIGH preferred). /// 2. Generates the deterministic contract id from @@ -170,7 +169,6 @@ impl IdentityWallet { identity_id: *owner_identity_id, purpose: Purpose::AUTHENTICATION, security_levels: CONTRACT_CREATE_SIGNING_LEVELS.to_vec(), - key_types: WALLET_SIGNING_KEY_TYPES.to_vec(), transition: "a contract-create state transition".to_string(), })? .clone() @@ -314,7 +312,7 @@ impl IdentityWallet { /// from `existing` keeps every untouched section intact. The /// function: /// 1. Looks up `owner_identity_id` in the in-memory wallet - /// manager and picks a CRITICAL AUTHENTICATION ECDSA key, the + /// manager and picks a CRITICAL AUTHENTICATION key, the /// only level DPP accepts for a contract-update signature. /// 2. Fetches the *current* contract from Platform and validates /// that it is actually owned by `owner_identity_id` — this @@ -384,7 +382,6 @@ impl IdentityWallet { identity_id: *owner_identity_id, purpose: Purpose::AUTHENTICATION, security_levels: CONTRACT_UPDATE_SIGNING_LEVELS.to_vec(), - key_types: WALLET_SIGNING_KEY_TYPES.to_vec(), transition: "a contract-update state transition".to_string(), })? .clone() diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs index 6ea7e81148c..af57b7b0cde 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/document.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/document.rs @@ -27,10 +27,7 @@ //! post-broadcast GroveDB proof-verification recursion. The owner's //! balance the proof carries (from protocol version 14) is kept. -use super::key_selection::{ - check_signing_key, usable_signing_key, SigningScope, PLATFORM_SIGNING_KEY_TYPES, - WALLET_SIGNING_KEY_TYPES, -}; +use super::key_selection::{check_signing_key, usable_signing_key, SigningScope}; use std::collections::BTreeMap; use std::sync::Arc; @@ -104,8 +101,8 @@ where } /// The key that signs a document batch in `scope` whose document type requires -/// `requirement`: an AUTHENTICATION key of one of [`WALLET_SIGNING_KEY_TYPES`] at a level -/// consensus accepts, in [`usable_signing_key`]'s order of preference. +/// `requirement`: an AUTHENTICATION key at a level consensus accepts, in +/// [`usable_signing_key`]'s order of preference. pub(super) fn document_signing_key<'a>( identity: &'a Identity, signer: &impl Signer, @@ -117,7 +114,6 @@ pub(super) fn document_signing_key<'a>( signer, Purpose::AUTHENTICATION, &SecurityLevel::accepted_for_requirement(requirement), - &WALLET_SIGNING_KEY_TYPES, scope, ) } @@ -136,7 +132,6 @@ pub(super) fn check_document_signing_key( signer, &[Purpose::AUTHENTICATION], &SecurityLevel::accepted_for_requirement(requirement), - &PLATFORM_SIGNING_KEY_TYPES, scope, ) } @@ -173,8 +168,9 @@ impl IdentityWallet { /// `(contract, owner, type, entropy)` there — the placeholder /// id this build step assigns is overwritten. /// 3. Selects the signing `IdentityPublicKey` from the in-memory - /// wallet manager: an AUTHENTICATION-purpose ECDSA key - /// (`ECDSA_SECP256K1` preferred, `ECDSA_HASH160` accepted) whose security level satisfies the document type's + /// wallet manager: an AUTHENTICATION-purpose key (a DIP-11 + /// type preferred, `ECDSA_HASH160` accepted) whose security + /// level satisfies the document type's /// `security_level_requirement()` and whose bounds cover the /// document type (see [`document_signing_key`]). Returns a clear error /// if the owner identity isn't loaded or carries no qualifying @@ -278,7 +274,7 @@ impl IdentityWallet { // 3. Owner identity + signing key from the wallet manager. The // document state transition must be signed by an - // AUTHENTICATION + ECDSA key whose security level satisfies + // AUTHENTICATION key whose security level satisfies // the document type's requirement (NOT a fixed CRITICAL like // the contract-create path). let required_level = document_type.security_level_requirement(); @@ -307,7 +303,6 @@ impl IdentityWallet { identity_id: *owner_identity_id, purpose: Purpose::AUTHENTICATION, security_levels: SecurityLevel::accepted_for_requirement(required_level), - key_types: WALLET_SIGNING_KEY_TYPES.to_vec(), transition: format!("a {document_type_name} document transition"), })? .clone() diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs index 6d709496f61..ce85f7afd2f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns.rs @@ -1,7 +1,7 @@ //! DPNS name registration, resolution, search, and contest queries. use super::document::{check_document_signing_key, document_signing_key}; -use super::key_selection::{SigningScope, WALLET_SIGNING_KEY_TYPES}; +use super::key_selection::SigningScope; use dpp::fee::Credits; @@ -20,7 +20,7 @@ use super::{DPNS_USERNAMES_MAX_PAGES, DPNS_USERNAMES_PAGE_LIMIT}; use super::*; /// The key that signs both DPNS registration documents, `preorder` then `domain`: an -/// AUTHENTICATION ECDSA key meeting `requirement` whose bounds cover the whole DPNS contract +/// AUTHENTICATION key meeting `requirement` whose bounds cover the whole DPNS contract /// `dpns_contract_id`. MASTER is reserved for identity operations and refused on document /// writes. Both come from the /// [`DpnsNameRegistration`](dash_sdk::platform::dpns_usernames::DpnsNameRegistration) the key @@ -245,7 +245,6 @@ impl IdentityWallet { identity_id: *identity_id, purpose: Purpose::AUTHENTICATION, security_levels: SecurityLevel::accepted_for_requirement(requirement), - key_types: WALLET_SIGNING_KEY_TYPES.to_vec(), transition: "a DPNS name registration".to_string(), })? .clone(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs index 24991575527..f9db3f3def1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -54,7 +54,7 @@ use crate::error::PlatformWalletError; use crate::wallet::identity::types::key_storage::DpnsNameInfo; use super::document::document_signing_key; -use super::key_selection::{SigningScope, WALLET_SIGNING_KEY_TYPES}; +use super::key_selection::SigningScope; use super::*; /// DPNS document type carrying registered names. @@ -960,7 +960,7 @@ impl IdentityWallet { // ----------------------------------------------------------------- /// Auto-select the signing key for a DPNS `domain` state transition - /// on `identity_id`: an AUTHENTICATION-purpose ECDSA key whose + /// on `identity_id`: an AUTHENTICATION-purpose key whose /// security level satisfies what `contract`'s `domain` type requires /// and whose bounds cover `domain`, chosen in /// [`document_signing_key`]'s order of preference. `contract` is the DPNS contract the @@ -1007,7 +1007,6 @@ impl IdentityWallet { identity_id: *identity_id, purpose: Purpose::AUTHENTICATION, security_levels: SecurityLevel::accepted_for_requirement(required_level), - key_types: WALLET_SIGNING_KEY_TYPES.to_vec(), transition: format!("a DPNS {DPNS_DOCUMENT_TYPE} document transition"), }) } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs b/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs index a4effaa601a..1557a322a95 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/key_selection.rs @@ -26,29 +26,6 @@ use super::signing_key::{first_available, require_available}; use std::cmp::Reverse; use std::time::{SystemTime, UNIX_EPOCH}; -/// The key types Platform verifies an identity signature by (Drive's -/// `validate_state_transition_identity_signed`). Pass these as `key_types` to -/// [`check_signing_key`] for a key the caller chose, so it is refused exactly where Platform -/// refuses it. The wallet's own choice of a credit transfer or withdrawal key uses them too. -/// Other automatic selection uses the narrower [`WALLET_SIGNING_KEY_TYPES`]. -pub const PLATFORM_SIGNING_KEY_TYPES: [KeyType; 3] = [ - KeyType::ECDSA_SECP256K1, - KeyType::BLS12_381, - KeyType::ECDSA_HASH160, -]; - -/// The key types the wallet picks its own signing keys from, except for a credit transfer or -/// withdrawal key, which may be any of [`PLATFORM_SIGNING_KEY_TYPES`]. Pass these as -/// `key_types` to [`usable_signing_key`] to choose the key the wallet itself would; a key the -/// caller chose is checked against [`PLATFORM_SIGNING_KEY_TYPES`] instead. -/// -/// `ECDSA_HASH160` is here so identities whose keys hold only the hash of the public key, the -/// default layout older Dash Evo Tool versions created, keep signing wherever Platform -/// accepts the key; [`usable_signing_key`] still prefers an `ECDSA_SECP256K1` key, the type -/// DIP-11 defines. -pub const WALLET_SIGNING_KEY_TYPES: [KeyType; 2] = - [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160]; - /// What a signature authorizes, as far as a key's contract bounds go. #[derive(Debug, Clone, Copy, PartialEq)] pub enum SigningScope<'a> { @@ -132,30 +109,39 @@ impl SigningScope<'_> { } /// The key of `identity` that should sign in `scope`: one of `purpose`, at one of -/// `security_levels`, of one of `key_types`, that Platform would accept now AND that `signer` -/// reports it can sign with. +/// `security_levels`, of a type Platform verifies an identity signature by +/// ([`KeyType::can_sign_identity_signed_transition`]), that Platform would accept now AND that +/// `signer` reports it can sign with. Which of those types the signer handles is the signer's +/// answer: a signer that cannot sign with a `BLS12_381` key says so in +/// [`Signer::can_sign_with`], and the key is skipped. /// /// A key whose contract bounds do not cover `scope` is skipped, and so is a key bound to a /// contract group: membership is state the wallet does not hold. A key without limits is /// preferred over any key with limits, whatever their security levels: a key with a budget or /// an expiry is an application key, taken only when no unlimited key qualifies. Within each -/// group a key of a type DIP-11 defines goes before an `ECDSA_HASH160` one, which holds only -/// the hash of its public key; then the weakest qualifying security level, then the lowest key -/// id: DIP-11 has clients ask for stronger authentication the stronger the key, so a HIGH -/// write signs with a HIGH key rather than a CRITICAL one, and a stronger key signs only when -/// the signer cannot reach a weaker one. A disabled key is skipped, and so is a key whose expiry has -/// passed the wall clock (the block time trails it by seconds at most), or any key with an -/// expiry when the system clock reads before the Unix epoch. What is left of a -/// budget is not known offline; a spent key is refused by Platform. +/// group a key of a type DIP-11 defines (`ECDSA_SECP256K1`, `BLS12_381`) goes before an +/// `ECDSA_HASH160` one, which holds only the hash of its public key and is the layout older +/// Dash Evo Tool versions created; then the weakest qualifying security level, then the +/// lowest key id: DIP-11 has clients ask for stronger authentication the stronger the key, so +/// a HIGH write signs with a HIGH key rather than a CRITICAL one, and a stronger key signs +/// only when the signer cannot reach a weaker one. A disabled key is skipped, and so is a key +/// whose expiry has passed the wall clock (the block time trails it by seconds at most), or +/// any key with an expiry when the system clock reads before the Unix epoch. What is left of +/// a budget is not known offline; a spent key is refused by Platform. /// /// `Ok(None)` means no key is eligible at all; `Err` means at least one eligible key exists /// but the signer cannot reach any of them. +/// +/// Known limitation: [`Signer::can_sign_with`] answers per key, so a signer cannot yet say +/// "I do not sign this key type" apart from "this key is locked or missing". An identity whose +/// only eligible keys are of a type the signer does not sign (a `BLS12_381` key with the +/// wallet's secp256k1-only signers) therefore gets the signer-unavailable `Err`, not +/// `Ok(None)`; when every refused key is `BLS12_381`, its message names both possible causes. pub fn usable_signing_key<'a>( identity: &'a Identity, signer: &impl Signer, purpose: Purpose, security_levels: &[SecurityLevel], - key_types: &[KeyType], scope: SigningScope<'_>, ) -> Result, dash_sdk::Error> { usable_signing_key_at( @@ -163,7 +149,6 @@ pub fn usable_signing_key<'a>( signer, purpose, security_levels, - key_types, scope, wall_clock_ms(), ) @@ -176,14 +161,13 @@ fn usable_signing_key_at<'a>( signer: &impl Signer, purpose: Purpose, security_levels: &[SecurityLevel], - key_types: &[KeyType], scope: SigningScope<'_>, now: Option, ) -> Result, dash_sdk::Error> { let qualifies = |key: &IdentityPublicKey| { key.purpose() == purpose && security_levels.contains(&key.security_level()) - && key_types.contains(&key.key_type()) + && key.key_type().can_sign_identity_signed_transition() && !key.is_disabled() && !expired_or_unknown(key, now) && scope.bounds_check(key) == BatchedTransitionBoundsCheck::Allowed @@ -211,7 +195,8 @@ fn usable_signing_key_at<'a>( /// applies, so an explicit choice is refused before anything is built or signed. /// /// Each refusal is the consensus signature error Platform itself returns for it, checked in -/// Drive's order: a key type outside `key_types`, a purpose outside `purposes`, a security +/// Drive's order: a key type Platform does not verify an identity signature by +/// ([`KeyType::can_sign_identity_signed_transition`]), a purpose outside `purposes`, a security /// level outside `security_levels`, a disabled key (identity-signed validation); then contract /// bounds that do not cover `scope` (structure validation); then an expired key (fee /// validation). A key bound to @@ -224,7 +209,6 @@ pub fn check_signing_key( signer: &impl Signer, purposes: &[Purpose], security_levels: &[SecurityLevel], - key_types: &[KeyType], scope: SigningScope<'_>, ) -> Result<(), dash_sdk::Error> { check_signing_key_at( @@ -232,7 +216,6 @@ pub fn check_signing_key( signer, purposes, security_levels, - key_types, scope, wall_clock_ms(), ) @@ -245,13 +228,12 @@ fn check_signing_key_at( signer: &impl Signer, purposes: &[Purpose], security_levels: &[SecurityLevel], - key_types: &[KeyType], scope: SigningScope<'_>, now: Option, ) -> Result<(), dash_sdk::Error> { // Drive's identity-signed validation checks in this order: key type, purpose, security // level, disabled. - if !key_types.contains(&key.key_type()) { + if !key.key_type().can_sign_identity_signed_transition() { return Err(signature_refusal( SignatureError::InvalidIdentityPublicKeyTypeError( InvalidIdentityPublicKeyTypeError::new(key.key_type()), @@ -337,24 +319,21 @@ fn signature_refusal(error: SignatureError) -> dash_sdk::Error { ProtocolError::ConsensusError(Box::new(ConsensusError::SignatureError(error))).into() } -/// The AUTHENTICATION key of `identity` at one of `security_levels`, of one of `key_types`, -/// that can sign a `document_type_name` document of `contract_id` now AND that `signer` can -/// sign with, preferred in [`usable_signing_key`]'s order: [`usable_signing_key`] for one -/// document type. +/// The AUTHENTICATION key of `identity` at one of `security_levels` that can sign a +/// `document_type_name` document of `contract_id` now AND that `signer` can sign with, +/// preferred in [`usable_signing_key`]'s order: [`usable_signing_key`] for one document type. pub(crate) fn usable_authentication_key<'a>( identity: &'a Identity, signer: &impl Signer, contract_id: Identifier, document_type_name: &str, security_levels: &[SecurityLevel], - key_types: &[KeyType], ) -> Result, dash_sdk::Error> { usable_signing_key( identity, signer, Purpose::AUTHENTICATION, security_levels, - key_types, SigningScope::DocumentType { contract_id, document_type_name, @@ -421,21 +400,161 @@ pub(super) mod tests { Identifier::from(CONTRACT), DOCUMENT_TYPE, &LEVELS, - &TYPES, ) .unwrap() } const LEVELS: [SecurityLevel; 2] = [SecurityLevel::HIGH, SecurityLevel::CRITICAL]; - /// The key types the wallet's own choice reads, as production passes them. - const TYPES: [KeyType; 2] = WALLET_SIGNING_KEY_TYPES; - fn hash160(mut key: IdentityPublicKey) -> IdentityPublicKey { - key.set_key_type(KeyType::ECDSA_HASH160); - key.set_data(BinaryData::new(vec![key.id() as u8; 20])); + fn of_type(mut key: IdentityPublicKey, key_type: KeyType) -> IdentityPublicKey { + key.set_key_type(key_type); + key.set_data(BinaryData::new(vec![ + key.id() as u8; + key_type.default_size() + ])); key } + fn hash160(key: IdentityPublicKey) -> IdentityPublicKey { + of_type(key, KeyType::ECDSA_HASH160) + } + + /// A signer like the wallet's own: it signs with every key except `BLS12_381` ones. + pub(crate) fn ecdsa_only_signer() -> KeyFilter bool + Send + Sync> + { + KeyFilter(|key: &IdentityPublicKey| key.key_type() != KeyType::BLS12_381) + } + + fn pick_by( + identity: &Identity, + signer: &impl Signer, + ) -> Result, dash_sdk::Error> { + usable_authentication_key( + identity, + signer, + Identifier::from(CONTRACT), + DOCUMENT_TYPE, + &LEVELS, + ) + .map(|key| key.map(|k| k.id())) + } + + #[test] + fn should_prefer_a_bls_key_over_a_hash160_key_when_the_signer_can_sign_bls() { + let identity = identity(vec![ + hash160(key(1, SecurityLevel::HIGH)), + of_type(key(2, SecurityLevel::CRITICAL), KeyType::BLS12_381), + ]); + assert_eq!( + pick_by(&identity, &KeyFilter(|_| true)).unwrap(), + Some(2), + "BLS12_381 is a DIP-11 key type, ranked before HASH160 whatever the level" + ); + } + + #[test] + fn should_skip_a_bls_key_a_signer_cannot_sign_with_for_a_hash160_one() { + let identity = identity(vec![ + of_type(key(1, SecurityLevel::HIGH), KeyType::BLS12_381), + hash160(key(2, SecurityLevel::HIGH)), + ]); + assert_eq!(pick_by(&identity, &ecdsa_only_signer()).unwrap(), Some(2)); + } + + fn refusal_message(error: dash_sdk::Error) -> String { + match error { + dash_sdk::Error::Protocol(ProtocolError::Generic(message)) => message, + other => panic!("expected a signer refusal, got {other:?}"), + } + } + + /// Pins the known limitation documented on [`usable_signing_key`]: a signer cannot yet tell + /// an unsupported key type apart from a locked key, so the message names both causes. + #[test] + fn should_name_the_key_type_when_a_signer_refuses_every_qualifying_bls_key() { + let identity = identity(vec![ + of_type(key(1, SecurityLevel::HIGH), KeyType::BLS12_381), + of_type(key(2, SecurityLevel::HIGH), KeyType::BLS12_381), + ]); + let error = pick_by(&identity, &ecdsa_only_signer()).unwrap_err(); + assert_eq!( + refusal_message(error), + format!( + "{SIGNER_KEY_UNAVAILABLE_PREFIX}Signing key 1 is unavailable to signer, or \ + signer does not support key type BLS12_381" + ) + ); + } + + #[test] + fn should_keep_the_unavailable_message_when_a_refused_key_is_not_bls() { + let expected = + format!("{SIGNER_KEY_UNAVAILABLE_PREFIX}Signing key 1 is unavailable to signer"); + let bls = || of_type(key(2, SecurityLevel::HIGH), KeyType::BLS12_381); + for keys in [ + vec![key(1, SecurityLevel::HIGH)], + vec![key(1, SecurityLevel::HIGH), bls()], + vec![ + of_type(key(1, SecurityLevel::HIGH), KeyType::BLS12_381), + key(2, SecurityLevel::HIGH), + ], + ] { + let error = pick_by(&identity(keys), &available(&[])).unwrap_err(); + assert_eq!(refusal_message(error), expected); + } + } + + #[test] + fn should_keep_the_unavailable_message_for_an_explicit_bls_key() { + let bls = of_type(key(1, SecurityLevel::HIGH), KeyType::BLS12_381); + let error = check_signing_key( + &bls, + &available(&[]), + &[Purpose::AUTHENTICATION], + &LEVELS, + SigningScope::NonBatch, + ) + .unwrap_err(); + assert_eq!( + refusal_message(error), + format!("{SIGNER_KEY_UNAVAILABLE_PREFIX}Signing key 1 is unavailable to signer") + ); + } + + #[test] + fn should_never_pick_a_key_type_platform_does_not_verify_identity_signatures_by() { + for key_type in KeyType::all_key_types() { + let identity = identity(vec![of_type(key(1, SecurityLevel::HIGH), key_type)]); + let expected = key_type.can_sign_identity_signed_transition().then_some(1); + assert_eq!( + pick_by(&identity, &KeyFilter(|_| true)).unwrap(), + expected, + "{key_type:?}" + ); + } + } + + #[test] + fn should_accept_an_explicit_key_of_every_type_platform_verifies_identity_signatures_by() { + for key_type in KeyType::all_key_types() { + let refusal = check( + &of_type(key(1, SecurityLevel::HIGH), key_type), + document_scope(), + ); + if key_type.can_sign_identity_signed_transition() { + refusal.expect("Platform verifies an identity signature by this key type"); + } else { + assert!( + matches!( + refusal.as_ref().err().and_then(signature_refusal_of), + Some(SignatureError::InvalidIdentityPublicKeyTypeError(_)) + ), + "{key_type:?}: {refusal:?}" + ); + } + } + } + #[test] fn prefers_a_key_without_limits_over_an_earlier_limited_one() { let identity = identity(vec![ @@ -549,15 +668,7 @@ pub(super) mod tests { identity: &Identity, ids: &[KeyID], ) -> Result, dash_sdk::Error> { - usable_authentication_key( - identity, - &available(ids), - Identifier::from(CONTRACT), - DOCUMENT_TYPE, - &LEVELS, - &TYPES, - ) - .map(|key| key.map(|k| k.id())) + pick_by(identity, &available(ids)) } fn bound_elsewhere(id: KeyID) -> IdentityPublicKey { @@ -629,7 +740,6 @@ pub(super) mod tests { &KeyFilter(|_| true), purpose, &SecurityLevel::full_range(), - &TYPES, scope, ) .unwrap() @@ -804,7 +914,6 @@ pub(super) mod tests { &KeyFilter(|_| true), &[Purpose::AUTHENTICATION], &LEVELS, - &TYPES, scope, ) } @@ -935,7 +1044,6 @@ pub(super) mod tests { &available(&[]), &[Purpose::AUTHENTICATION], &LEVELS, - &TYPES, document_scope(), ) .unwrap_err() @@ -1026,7 +1134,6 @@ pub(super) mod tests { &KeyFilter(|_| true), Purpose::AUTHENTICATION, &LEVELS, - &TYPES, document_scope(), None, ) @@ -1049,7 +1156,6 @@ pub(super) mod tests { &KeyFilter(|_| true), &[Purpose::AUTHENTICATION], &LEVELS, - &TYPES, document_scope(), None, ); @@ -1190,7 +1296,6 @@ pub(super) mod tests { &KeyFilter(|_| true), Purpose::AUTHENTICATION, &LEVELS, - &TYPES, scope, ) .unwrap() diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index c515333e9a4..18d93d996cf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -54,10 +54,7 @@ mod update; #[cfg(test)] pub(crate) use key_selection::tests::signature_refusal_of; pub(crate) use key_selection::usable_authentication_key; -pub use key_selection::{ - check_signing_key, usable_signing_key, SigningScope, PLATFORM_SIGNING_KEY_TYPES, - WALLET_SIGNING_KEY_TYPES, -}; +pub use key_selection::{check_signing_key, usable_signing_key, SigningScope}; mod withdrawal; pub(crate) use withdrawal::{select_owner_withdrawal_key, select_transfer_withdrawal_key}; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index 14e82b28e91..ac54fe16183 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -9,7 +9,6 @@ use dpp::identity::accessors::IdentityGettersV0; #[cfg(test)] use dpp::identity::identity_public_key::Purpose; use dpp::identity::signer::Signer; -use dpp::identity::KeyType; use dpp::identity::SecurityLevel; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::Value; @@ -30,6 +29,7 @@ use crate::wallet::identity::{ // for identity operations and cannot authorize an ordinary document write. A key // bound to another contract or document type is skipped, a key without limits is // preferred, and an expired one or one the signer cannot sign with is skipped. +// HASH160 keys sign too, after any raw-public-key one. fn profile_signing_key<'a>( identity: &'a Identity, dashpay_contract_id: Identifier, @@ -41,7 +41,6 @@ fn profile_signing_key<'a>( dashpay_contract_id, "profile", &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], - &[KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160], ) } @@ -580,8 +579,10 @@ fn cacheable_profile(props: &std::collections::BTreeMap) -> Optio #[cfg(test)] mod tests { + use super::super::key_selection::tests::ecdsa_only_signer; use super::super::signing_key::tests::KeyFilter; use super::*; + use dpp::identity::KeyType; use std::collections::BTreeMap; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -642,17 +643,22 @@ mod tests { fn should_reject_ineligible_profile_keys() { let empty = Identity::default_versioned(PlatformVersion::latest()).unwrap(); assert!(pick(&empty).is_none()); - for key_type in [ - KeyType::BLS12_381, - KeyType::BIP13_SCRIPT_HASH, - KeyType::EDDSA_25519_HASH160, - ] { + for key_type in [KeyType::BIP13_SCRIPT_HASH, KeyType::EDDSA_25519_HASH160] { assert!(pick(&identity_with_key(profile_key( key_type, SecurityLevel::HIGH ))) .is_none()); } + let bls = identity_with_key(profile_key(KeyType::BLS12_381, SecurityLevel::HIGH)); + assert!( + profile_signing_key(&bls, Identifier::from(DASHPAY), &ecdsa_only_signer()).is_err(), + "a signer that cannot sign with a BLS key is never handed one" + ); + assert!( + pick(&bls).is_some(), + "Platform verifies an identity signature by a BLS key" + ); for key_type in [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160] { for level in [SecurityLevel::MASTER, SecurityLevel::MEDIUM] { assert!(pick(&identity_with_key(profile_key(key_type, level))).is_none()); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index 29aa6e7bef1..9c5d62e4952 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -1,13 +1,10 @@ //! Signer-aware identity key selection shared by wallet operations. -use super::key_selection::{ - check_signing_key, usable_signing_key, SigningScope, PLATFORM_SIGNING_KEY_TYPES, - WALLET_SIGNING_KEY_TYPES, -}; +use super::key_selection::{check_signing_key, usable_signing_key, SigningScope}; use crate::error::SIGNER_KEY_UNAVAILABLE_PREFIX; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; -use dpp::identity::{Identity, IdentityPublicKey, Purpose, SecurityLevel}; +use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::ProtocolError; /// Preserve the signer's unavailable-key discriminator through SDK and FFI errors. @@ -19,6 +16,18 @@ fn signing_key_unavailable(key: &IdentityPublicKey) -> dash_sdk::Error { .into() } +/// [`signing_key_unavailable`] for automatic selection that found only `BLS12_381` keys, which +/// the wallet's secp256k1-only signers refuse whether or not the key is present. +fn bls_signing_key_unavailable(key: &IdentityPublicKey) -> dash_sdk::Error { + ProtocolError::Generic(format!( + "{SIGNER_KEY_UNAVAILABLE_PREFIX}Signing key {} is unavailable to signer, or signer does \ + not support key type {}", + key.id(), + KeyType::BLS12_381 + )) + .into() +} + /// `key` itself, or the signer-unavailable error when `signer` cannot sign with it. pub(super) fn require_available<'a>( key: &'a IdentityPublicKey, @@ -38,13 +47,19 @@ pub(super) fn first_available<'a>( signer: &impl Signer, ) -> Result, dash_sdk::Error> { let mut unavailable = None; + let mut all_bls = true; for key in keys { if signer.can_sign_with(key) { return Ok(Some(key)); } unavailable.get_or_insert(key); + all_bls &= key.key_type() == KeyType::BLS12_381; + } + match unavailable { + None => Ok(None), + Some(key) if all_bls => Err(bls_signing_key_unavailable(key)), + Some(key) => Err(signing_key_unavailable(key)), } - unavailable.map_or(Ok(None), |key| Err(signing_key_unavailable(key))) } /// The security levels a contract create accepts: HIGH or stronger short of MASTER, as @@ -59,7 +74,7 @@ pub(super) const CONTRACT_UPDATE_SIGNING_LEVELS: [SecurityLevel; 1] = [SecurityL /// The security levels [`master_signing_key`] accepts. pub(super) const MASTER_SIGNING_LEVELS: [SecurityLevel; 1] = [SecurityLevel::MASTER]; -/// The key that signs a contract create or update: an AUTHENTICATION ECDSA key at one of +/// The key that signs a contract create or update: an AUTHENTICATION key at one of /// `security_levels`, [`CONTRACT_CREATE_SIGNING_LEVELS`] or [`CONTRACT_UPDATE_SIGNING_LEVELS`]. /// These are not batches, so a contract-bound key cannot sign them. pub(super) fn contract_signing_key<'a>( @@ -72,7 +87,6 @@ pub(super) fn contract_signing_key<'a>( signer, Purpose::AUTHENTICATION, security_levels, - &WALLET_SIGNING_KEY_TYPES, SigningScope::NonBatch, ) } @@ -87,7 +101,6 @@ pub(super) fn master_signing_key<'a>( signer, Purpose::AUTHENTICATION, &MASTER_SIGNING_LEVELS, - &WALLET_SIGNING_KEY_TYPES, SigningScope::NonBatch, ) } @@ -119,7 +132,6 @@ pub(crate) fn credit_signing_key<'a>( signer, &purposes, &CREDIT_SIGNING_LEVELS, - &PLATFORM_SIGNING_KEY_TYPES, SigningScope::NonBatch, )?; return Ok(key); @@ -131,7 +143,6 @@ pub(crate) fn credit_signing_key<'a>( signer, purpose, &CREDIT_SIGNING_LEVELS, - &PLATFORM_SIGNING_KEY_TYPES, SigningScope::NonBatch, ) { Ok(Some(key)) => return Ok(key), @@ -160,7 +171,6 @@ pub(super) mod tests { use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use dpp::identity::KeyType; use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use dpp::version::PlatformVersion; @@ -267,7 +277,6 @@ pub(super) mod tests { &available(ids), Purpose::AUTHENTICATION, &[SecurityLevel::HIGH], - &[KeyType::ECDSA_HASH160], SigningScope::NonBatch, ) .map(|key| key.map(|key| key.id())) @@ -281,36 +290,32 @@ pub(super) mod tests { #[test] fn should_not_relax_eligibility_for_available_keys() { - use KeyType::{ECDSA_HASH160 as HASH160, ECDSA_SECP256K1 as SECP}; use Purpose::{AUTHENTICATION as AUTH, TRANSFER}; use SecurityLevel::{CRITICAL, HIGH}; - let mut identity = identity(AUTH, CRITICAL, SECP); + let mut identity = identity(AUTH, CRITICAL, KeyType::ECDSA_SECP256K1); let key = identity.public_keys_mut().get_mut(&1).unwrap(); key.set_disabled_at(1); - let select = |purpose, level, key_type| { + let select = |identity: &Identity, purpose, level| { let signer = available(&[1, 2]); - let key = usable_signing_key( - &identity, - &signer, - purpose, - &[level], - &[key_type], - SigningScope::NonBatch, - ); + let key = + usable_signing_key(identity, &signer, purpose, &[level], SigningScope::NonBatch); key.unwrap().map(|k| k.id()) }; assert_eq!( - select(AUTH, CRITICAL, SECP), + select(&identity, AUTH, CRITICAL), Some(2), "disabled key 1 is skipped" ); - for (purpose, level, key_type) in [ - (TRANSFER, CRITICAL, SECP), - (AUTH, HIGH, SECP), - (AUTH, CRITICAL, HASH160), - ] { - assert_eq!(select(purpose, level, key_type), None); + for (purpose, level) in [(TRANSFER, CRITICAL), (AUTH, HIGH)] { + assert_eq!(select(&identity, purpose, level), None); } + let key = identity.public_keys_mut().get_mut(&2).unwrap(); + key.set_key_type(KeyType::EDDSA_25519_HASH160); + assert_eq!( + select(&identity, AUTH, CRITICAL), + None, + "Platform does not verify an identity signature by an EdDSA key" + ); } #[test] @@ -889,11 +894,9 @@ pub(super) mod tests { identity_id, purpose: Purpose::AUTHENTICATION, security_levels, - key_types, .. } if *identity_id == identity && security_levels == &[level] - && key_types == &WALLET_SIGNING_KEY_TYPES ), "{error:?}" ); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/helpers.rs b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/helpers.rs index f2a9771a2b8..c695874d084 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/tokens/helpers.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/tokens/helpers.rs @@ -11,10 +11,7 @@ //! all rejected by Drive. See //! `state_transitions/document/batch_transition/methods/v0/mod.rs:133-138`. -use super::super::key_selection::{ - check_signing_key, usable_signing_key, SigningScope, PLATFORM_SIGNING_KEY_TYPES, - WALLET_SIGNING_KEY_TYPES, -}; +use super::super::key_selection::{check_signing_key, usable_signing_key, SigningScope}; use std::sync::Arc; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -42,12 +39,12 @@ fn token_signing_purposes(action: TokenTransitionActionType) -> &'static [Purpos } /// The key that signs a token `action` on the token at `position` of `token_contract_id`: a -/// CRITICAL key of one of [`WALLET_SIGNING_KEY_TYPES`] with one of the purposes -/// [`token_signing_purposes`] allows, whose bounds cover that token. A purpose is tried only -/// when no key of the ones before it qualifies, so an identity holding a qualifying -/// AUTHENTICATION key keeps the outcome it always had, the signer-unavailable error -/// included, and a TRANSFER key signs only for an identity without one. Within one purpose -/// the order is [`usable_signing_key`]'s. +/// CRITICAL key with one of the purposes [`token_signing_purposes`] allows, of a key type +/// Platform verifies an identity signature by, whose bounds cover that token. A purpose is +/// tried only when no key of the ones before it qualifies, so an identity holding a +/// qualifying AUTHENTICATION key keeps the outcome it always had, the signer-unavailable +/// error included, and a TRANSFER key signs only for an identity without one. Within one +/// purpose the order is [`usable_signing_key`]'s. /// /// `Ok(None)` means no key of any allowed purpose is eligible; `Err` means the first purpose /// with an eligible key has none the signer reaches. @@ -64,7 +61,6 @@ fn token_signing_key<'a>( signer, purpose, &TOKEN_SIGNING_LEVELS, - &WALLET_SIGNING_KEY_TYPES, SigningScope::Token { contract_id: token_contract_id, position, @@ -93,7 +89,6 @@ pub(super) fn check_token_signing_key( signer, token_signing_purposes(action), &TOKEN_SIGNING_LEVELS, - &PLATFORM_SIGNING_KEY_TYPES, SigningScope::Token { contract_id: data_contract.id(), position, @@ -108,8 +103,8 @@ impl IdentityWallet { /// which on-chain key id to sign with. /// /// Key selection is a fixed canonical policy: - /// a purpose `action` allows × `SecurityLevel::CRITICAL` × `WALLET_SIGNING_KEY_TYPES`, - /// bounds covering `token_contract_id` (see `token_signing_key`). + /// a purpose `action` allows × `SecurityLevel::CRITICAL`, of a key type Platform verifies + /// an identity signature by, bounds covering `token_contract_id` (see `token_signing_key`). /// Callers that pass a `signing_key_id` over FFI are advisory — /// this Rust path is authoritative and Drive validates the chosen /// key matches what the state transition needs. @@ -139,7 +134,6 @@ impl IdentityWallet { identity_id: *identity_id, purpose: Purpose::AUTHENTICATION, security_levels: TOKEN_SIGNING_LEVELS.to_vec(), - key_types: WALLET_SIGNING_KEY_TYPES.to_vec(), transition: format!("a token transition of contract {token_contract_id}"), })? .clone(); 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 73cb4af1747..89d6bd8a763 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -22,7 +22,6 @@ use dash_sdk::platform::transition::put_settings::PutSettings; use crate::changeset::{KeyWithBreadcrumb, PersistenceError}; use crate::error::{as_identity_revision_invalid, PlatformWalletError}; -use super::key_selection::WALLET_SIGNING_KEY_TYPES; use super::signing_key::{master_signing_key, MASTER_SIGNING_LEVELS}; use super::*; @@ -150,7 +149,6 @@ impl IdentityWallet { identity_id: *identity_id, purpose: Purpose::AUTHENTICATION, security_levels: MASTER_SIGNING_LEVELS.to_vec(), - key_types: WALLET_SIGNING_KEY_TYPES.to_vec(), transition: "an identity update".to_string(), })?; diff --git a/packages/rs-unified-sdk-jni/src/signer.rs b/packages/rs-unified-sdk-jni/src/signer.rs index d21a8c1ccf0..98157027570 100644 --- a/packages/rs-unified-sdk-jni/src/signer.rs +++ b/packages/rs-unified-sdk-jni/src/signer.rs @@ -20,8 +20,9 @@ use crate::results::{unwrap_handle, unwrap_signature}; use crate::support::{guard, JVM}; use jni::objects::{GlobalRef, JByteArray, JClass, JObject, JString}; -use jni::sys::{jbyteArray, jint, jlong}; +use jni::sys::{jboolean, jbyteArray, jint, jlong}; use jni::JNIEnv; +use platform_wallet_ffi::dash_sdk_resolver_supports_key_type; use rs_sdk_ffi::{ dash_sdk_sign_with_mnemonic_and_path, dash_sdk_signer_create_from_private_key, dash_sdk_signer_create_with_ctx, dash_sdk_signer_destroy, dash_sdk_signer_sign, @@ -329,6 +330,31 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith }) } +/// Whether Rust signs an identity key of DPP `KeyType` discriminant +/// `key_type`: the set `dash_sdk_resolver_supports_key_type` reports, the +/// predicate `KeychainSigner.swift` asks. A value outside `u8` names no key +/// type, so it is refused rather than truncated onto one. +fn supports_identity_key_type(key_type: jint) -> bool { + u8::try_from(key_type) + // SAFETY: the predicate takes a plain `u8` by value and touches no + // pointers. + .is_ok_and(|key_type| unsafe { dash_sdk_resolver_supports_key_type(key_type) }) +} + +/// `KeystoreSigner.canSignWith` asks this instead of keeping its own list of +/// signable identity key types, so Kotlin and Swift answer from the one Rust +/// predicate ([`supports_identity_key_type`]). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_resolverSupportsKeyType( + mut env: JNIEnv, + _class: JClass, + key_type: jint, +) -> jboolean { + guard(&mut env, jboolean::from(false), |_env| { + jboolean::from(supports_identity_key_type(key_type)) + }) +} + /// One-shot derive-then-sign for platform-address keys — the Kotlin /// counterpart of `KeychainSigner.signPlatformAddressOnDemand` on iOS. /// @@ -486,3 +512,25 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_SignerNative_signWith result }) } + +#[cfg(test)] +mod tests { + use super::supports_identity_key_type; + + #[test] + fn should_sign_only_ecdsa_identity_key_types() { + assert!(supports_identity_key_type(0)); // ECDSA_SECP256K1 + assert!(supports_identity_key_type(2)); // ECDSA_HASH160 + assert!(!supports_identity_key_type(1)); // BLS12_381 + assert!(!supports_identity_key_type(3)); // BIP13_SCRIPT_HASH + assert!(!supports_identity_key_type(4)); // EDDSA_25519_HASH160 + } + + #[test] + fn should_refuse_a_key_type_outside_u8_rather_than_truncate_it() { + assert!(!supports_identity_key_type(0x100)); // would truncate to 0 + assert!(!supports_identity_key_type(0x102)); // would truncate to 2 + assert!(!supports_identity_key_type(-1)); + assert!(!supports_identity_key_type(jni::sys::jint::MIN)); + } +} diff --git a/packages/simple-signer/src/signer.rs b/packages/simple-signer/src/signer.rs index 3a10d345941..5ae9c63aba5 100644 --- a/packages/simple-signer/src/signer.rs +++ b/packages/simple-signer/src/signer.rs @@ -202,10 +202,13 @@ impl Signer for SimpleSigner { } fn can_sign_with(&self, identity_public_key: &IdentityPublicKey) -> bool { - self.private_keys - .get(identity_public_key) - .or_else(|| self.private_keys_in_creation.get(identity_public_key)) - .is_some() + // `sign` refuses a BIP13_SCRIPT_HASH key whatever private key is held. + identity_public_key.key_type() != KeyType::BIP13_SCRIPT_HASH + && self + .private_keys + .get(identity_public_key) + .or_else(|| self.private_keys_in_creation.get(identity_public_key)) + .is_some() } } @@ -260,3 +263,33 @@ impl Signer for SimpleSigner { } } } + +#[cfg(test)] +mod tests { + use super::*; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + + #[test] + fn should_claim_only_keys_it_holds_of_a_type_it_signs() { + let mut signer = SimpleSigner::default(); + for key_type in KeyType::all_key_types() { + let key: IdentityPublicKey = IdentityPublicKeyV0 { + id: key_type as u32, + key_type, + data: vec![key_type as u8; key_type.default_size()].into(), + ..Default::default() + } + .into(); + assert!( + !signer.can_sign_with(&key), + "{key_type:?} without a private key" + ); + signer.add_identity_public_key(key.clone(), [1; 32]); + assert_eq!( + signer.can_sign_with(&key), + key_type != KeyType::BIP13_SCRIPT_HASH, + "{key_type:?}" + ); + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift index a0c1ac657e0..a9657170c67 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/FFI/KeychainSigner.swift @@ -530,7 +530,19 @@ public final class KeychainSigner: Signer, @unchecked Sendable { /// per-wallet mnemonic Keychain item exist — the two inputs the /// derive-and-sign FFI requires. We do NOT actually derive a key /// here; the check is purely "are the prerequisites in place". + /// + /// Every identity-key sign path here (scoped key, resolver, stored + /// scalar via `ffiSign`) produces a secp256k1 ECDSA signature, so only + /// `ECDSA_SECP256K1` and `ECDSA_HASH160` keys are signable. Rust picks + /// signing keys by this answer, so claiming a BLS or EdDSA key would get + /// it chosen and then signed with the wrong algorithm. func canSign(publicKey: Data, keyType: UInt8) -> Bool { + if keyType != Self.platformAddressHashKeyType, + !Self.resolverCanDeriveSign(keyType: keyType) + { + return false + } + if additionalSigningKey(publicKey: publicKey) != nil { return true } @@ -562,7 +574,8 @@ public final class KeychainSigner: Signer, @unchecked Sendable { } ) if let row = try? context.fetch(descriptor).first { - // Stored scalar present (legacy / not-yet-backfilled key). + // Stored scalar present (legacy / not-yet-backfilled key); + // the key type was checked on entry. if row.privateKeyKeychainIdentifier != nil { found = true return @@ -571,13 +584,8 @@ public final class KeychainSigner: Signer, @unchecked Sendable { // the two inputs `signIdentityKeyOnDemand` needs to derive-sign. // Match its `wid.count == 32` precondition so this preflight // doesn't report a corrupt-walletId row as signable. The key - // type must also be one the resolver actually derive-signs — a - // breadcrumb-only row (no stored scalar) whose type the resolver - // rejects would pass preflight but fail at sign time with - // `publicKeyNotFound` (the sign path routes an unsupported type - // to the — here absent — stored scalar). - if Self.resolverCanDeriveSign(keyType: keyType), - let wid = row.walletId, + // type was checked on entry against the resolver's own set. + if let wid = row.walletId, wid.count == 32, let path = row.identityDerivationPath, !path.isEmpty, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyBreadcrumbTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyBreadcrumbTests.swift index 4135ef603ea..c099ac9a8c8 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyBreadcrumbTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyBreadcrumbTests.swift @@ -285,11 +285,11 @@ final class IdentityKeyBreadcrumbTests: XCTestCase { ) } - /// The stored-scalar branch is key-type independent (the scalar signs via - /// `ffiSign` regardless of the declared type), so a row carrying a keychain - /// identifier preflights as signable for any key type — the resolver gate - /// only governs the breadcrumb-only path. Deterministic: no mnemonic read. - func testCanSignAcceptsStoredScalarRegardlessOfKeyType() throws { + /// The stored scalar signs via `ffiSign`, which is secp256k1 ECDSA only, so + /// a row carrying a keychain identifier preflights as signable for the two + /// ECDSA key types and for nothing else: Rust picks signing keys by this + /// answer. Deterministic: no mnemonic read. + func testCanSignAcceptsStoredScalarOnlyForEcdsaKeyTypes() throws { let container = try DashModelContainer.createInMemory() let seed = ModelContext(container) let pubKey = Data(repeating: 0x99, count: 33) @@ -305,8 +305,16 @@ final class IdentityKeyBreadcrumbTests: XCTestCase { "a stored-scalar row is signable for an ECDSA type" ) XCTAssertTrue( + signer.canSign(publicKey: pubKey, keyType: KeyType.ecdsaHash160.rawValue), + "a stored-scalar row is signable for a HASH160 ECDSA type" + ) + XCTAssertFalse( + signer.canSign(publicKey: pubKey, keyType: KeyType.bls12_381.rawValue), + "the stored scalar cannot produce a BLS signature" + ) + XCTAssertFalse( signer.canSign(publicKey: pubKey, keyType: KeyType.eddsa25519Hash160.rawValue), - "a stored-scalar row is signable regardless of key type" + "the stored scalar cannot produce an EdDSA signature" ) }