diff --git a/docs/user-stories.md b/docs/user-stories.md index b8ca7239b..ad5d35f5f 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -851,6 +851,15 @@ As a user, I want to remove an identity from this device from the identity's own --- +### IDN-024: Signing avoids keys that cannot sign [Implemented] +**Persona:** Alex, Priya + +As a user, I want the app to sign with a key that can actually sign the action, so that my actions are not refused because of an expired, used-up or out-of-scope key. + +- Automatic key choice skips disabled and expired keys, keys limited to other contracts or document types, and keys bound to a contract group. +- Only keys this device holds the private key of are chosen; among those, one without limits is preferred over a limited one. +- Where the user picks the key, limited and expired keys stay selectable, and the key chooser shows a warning explaining the limit. Keys bound to another contract or document type are not offered. + ## DPNS (DPN) ### DPN-001: Register a username [Implemented] @@ -1110,7 +1119,8 @@ As a user, I want to view past payments sent and received through DashPay so tha As an everyday user, I want to generate a DashPay QR code so that someone nearby can scan it to add me as a contact or send a payment. -- QR code encodes DashPay profile or payment info. +- The QR code is a DashPay contact code in the standard DashPay format (DIP-15), so other DashPay wallets can scan it. It shows the identity's username and works for one hour. +- Scanning another wallet's contact QR code sends that contact a request it can accept automatically. An expired or unreadable code is refused before anything is sent. ### DPY-009: Edit contact info [Implemented] **Persona:** Alex, Priya diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index 9ecf9aba8..f4f4c08d1 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -4,8 +4,6 @@ use crate::context::AppContext; use dash_sdk::Sdk; use std::sync::Arc; -pub mod auto_accept_handler; -pub mod auto_accept_proof; pub mod avatar_processing; pub mod contact_info; pub mod contact_requests; @@ -85,12 +83,12 @@ pub enum DashPayTask { to_username: String, account_label: Option, }, + /// Send a contact request to the owner of a scanned contact QR code, + /// carrying the auto-accept proof its key signs. SendContactRequestWithProof { identity: QualifiedIdentity, signing_key: IdentityPublicKey, - to_identity_id: Identifier, - account_label: Option, - qr_auto_accept: crate::backend_task::dashpay::auto_accept_proof::AutoAcceptProofData, + qr: crate::model::dashpay::ContactQr, }, AcceptContactRequest { identity: QualifiedIdentity, @@ -138,13 +136,11 @@ pub enum DashPayTask { DetectIncomingContactPayments { outputs: Vec, }, - /// Build an auto-accept contact QR payload. Resolves the ENCRYPTION key and - /// derives the auto-accept key through the JIT chokepoint in the backend, so - /// the UI never reads a seed. + /// Build the identity's contact QR code through upstream platform-wallet; + /// the wallet seed is read through the secret seam in the backend, so the + /// UI never reads a seed. GenerateAutoAcceptQrCode { identity: QualifiedIdentity, - account_reference: u32, - validity_hours: u32, }, } @@ -206,18 +202,15 @@ impl AppContext { DashPayTask::SendContactRequestWithProof { identity, signing_key, - to_identity_id, - account_label, - qr_auto_accept, + qr, } => Ok(contact_requests::send_contact_request_with_proof( self, sdk, identity, signing_key, - to_identity_id - .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), - account_label, - Some(qr_auto_accept), + qr.dpns_name(), + None, + Some(qr), ) .await?), DashPayTask::AcceptContactRequest { @@ -375,20 +368,13 @@ impl AppContext { )) } } - DashPayTask::GenerateAutoAcceptQrCode { - identity, - account_reference, - validity_hours, - } => { - let proof = auto_accept_proof::generate_auto_accept_proof( - &identity, - account_reference, - validity_hours, - ) - .await?; - Ok(BackendTaskSuccessResult::DashPayAutoAcceptQrCode( - proof.to_qr_string(), - )) + DashPayTask::GenerateAutoAcceptQrCode { identity } => { + let seed_hash = self.contact_qr_wallet_seed_hash(&identity)?; + let uri = self + .wallet_backend()? + .dashpay_build_auto_accept_qr(&identity, seed_hash) + .await?; + Ok(BackendTaskSuccessResult::DashPayAutoAcceptQrCode(uri)) } } } diff --git a/src/backend_task/dashpay/auto_accept_handler.rs b/src/backend_task/dashpay/auto_accept_handler.rs deleted file mode 100644 index 95781088a..000000000 --- a/src/backend_task/dashpay/auto_accept_handler.rs +++ /dev/null @@ -1,117 +0,0 @@ -use crate::backend_task::dashpay::auto_accept_proof::verify_auto_accept_proof; -use crate::backend_task::dashpay::contact_request_query; -use crate::backend_task::dashpay::contact_requests::accept_contact_request; -use crate::backend_task::error::TaskError; -use crate::context::AppContext; -use crate::model::qualified_identity::QualifiedIdentity; -use dash_sdk::Sdk; -use dash_sdk::dpp::document::DocumentV0Getters; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::platform_value::Value; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; -use dash_sdk::platform::{Document, FetchMany, Identifier}; -use std::sync::Arc; - -/// Process incoming contact requests and check for autoAcceptProof -/// -/// This function checks all incoming contact requests for valid autoAcceptProof -/// and automatically accepts and reciprocates if the proof is valid. -pub async fn process_auto_accept_requests( - app_context: &Arc, - sdk: &Sdk, - identity: QualifiedIdentity, -) -> Result, TaskError> { - let identity_id = identity.identity.id(); - - // Query for incoming contact requests - let mut incoming_query = contact_request_query(app_context)?; - - incoming_query = incoming_query.with_where(WhereClause { - field: "toUserId".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(identity_id.to_buffer()), - }); - - // Add orderBy to avoid platform bug - incoming_query = incoming_query.with_order_by(OrderClause { - field: "$createdAt".to_string(), - ascending: true, - }); - incoming_query.limit = 100; - - let incoming_docs = Document::fetch_many(sdk, incoming_query).await?; - - // Stateless verification; no stored proofs needed - - let mut auto_accepted_requests = Vec::new(); - - for (request_id, doc) in incoming_docs { - if let Some(doc) = doc { - let from_id = doc.owner_id(); - let props = doc.properties(); - - // Check if this request has an autoAcceptProof - if let Some(Value::Bytes(proof_data)) = props.get("autoAcceptProof") { - tracing::debug!( - "Found contact request with autoAcceptProof from {}", - from_id.to_string(Encoding::Base58) - ); - - // Extract accountReference for message construction (default to 0 if missing) - let account_reference = props - .get("accountReference") - .and_then(|value| value.to_integer::().ok()) - .unwrap_or_default(); - - // Verify the proof per DIP-0015 - match verify_auto_accept_proof( - proof_data, - from_id, - identity.identity.id(), - &identity, - account_reference, - ) - .await - { - Ok(true) => { - tracing::debug!( - "Valid autoAcceptProof, auto-accepting contact request from {}", - from_id.to_string(Encoding::Base58) - ); - - // Accept the request (which sends a reciprocal request) - match accept_contact_request(app_context, sdk, identity.clone(), request_id) - .await - { - Ok(_) => { - auto_accepted_requests.push((from_id, true)); - - // Stateless: no persistence required - } - Err(e) => { - tracing::error!("Failed to auto-accept contact request: {}", e); - auto_accepted_requests.push((from_id, false)); - } - } - } - Ok(false) => { - tracing::warn!( - "Invalid or expired autoAcceptProof from {}", - from_id.to_string(Encoding::Base58) - ); - } - Err(e) => { - tracing::error!("Failed to verify autoAcceptProof: {}", e); - } - } - } - } - } - - Ok(auto_accepted_requests) -} - -// No DB persistence required - -// Proof creation moved to contact_requests::send_contact_request_with_proof diff --git a/src/backend_task/dashpay/auto_accept_proof.rs b/src/backend_task/dashpay/auto_accept_proof.rs deleted file mode 100644 index 74b3cbd3c..000000000 --- a/src/backend_task/dashpay/auto_accept_proof.rs +++ /dev/null @@ -1,365 +0,0 @@ -use crate::backend_task::error::TaskError; -use crate::model::dashpay_derivation::derive_auto_accept_key; -use crate::model::qualified_identity::QualifiedIdentity; -use dash_sdk::dpp::dashcore::ecdsa::EcdsaSecretKey; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; -use dash_sdk::dpp::secp256k1::Message; -use dash_sdk::platform::Identifier; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::collections::HashSet; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct AutoAcceptProofData { - pub identity_id: Identifier, - pub proof_key: [u8; 32], - pub account_reference: u32, - pub expires_at: u64, // Unix timestamp -} - -impl AutoAcceptProofData { - pub fn to_qr_string(&self) -> String { - // Format according to DIP-0015: dash:?du={username}&dapk={key_data} - // Key data format: key_type (1 byte) + timestamp (4 bytes) + key_size (1 byte) + key (32 bytes) - let mut key_data = Vec::new(); - key_data.push(0u8); // Key type 0 for ECDSA_SECP256K1 - key_data.extend_from_slice(&(self.expires_at as u32).to_be_bytes()); // Timestamp/expiration - key_data.push(32u8); // Key size - key_data.extend_from_slice(&self.proof_key); // The actual key - - // Encode key data in base58 using dashcore's base58 implementation - let key_data_base58 = dash_sdk::dpp::platform_value::string_encoding::encode( - &key_data, - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - ); - - // For QR codes without username (identity-based) - format!( - "dash:?di={}&dapk={}", - self.identity_id - .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), - key_data_base58 - ) - } - - pub fn from_qr_string(qr_data: &str) -> Result { - // Parse DIP-0015 format: dash:?du={username}&dapk={key_data} or dash:?di={identity}&dapk={key_data} - if !qr_data.starts_with("dash:?") { - return Err("Invalid QR code format - must start with 'dash:?'".to_string()); - } - - let query_string = &qr_data[6..]; // Skip "dash:?" - let mut identity_id = None; - let mut key_data_base58 = None; - let mut account_reference = 0u32; // Default to account 0 - - // Parse query parameters - for param in query_string.split('&') { - let parts: Vec<&str> = param.split('=').collect(); - if parts.len() != 2 { - continue; - } - - match parts[0] { - "di" => { - identity_id = Some( - Identifier::from_string( - parts[1], - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58, - ) - .map_err(|e| format!("Invalid identity ID: {}", e))?, - ) - } - "dapk" => { - key_data_base58 = Some(parts[1].to_string()); - } - "account" => { - account_reference = parts[1] - .parse::() - .map_err(|e| format!("Invalid account reference: {}", e))?; - } - _ => {} // Ignore unknown parameters - } - } - - let identity_id = identity_id.ok_or("Missing identity ID in QR code".to_string())?; - let key_data_base58 = - key_data_base58.ok_or("Missing proof key data in QR code".to_string())?; - - // Decode the key data from base58 - let key_data = dash_sdk::dpp::dashcore::base58::decode(&key_data_base58) - .map_err(|e| format!("Invalid base58 key data: {}", e))?; - - // Parse key data format: key_type (1) + timestamp (4) + key_size (1) + key (32-64) - if key_data.len() < 38 { - return Err("Key data too short".to_string()); - } - - let _key_type = key_data[0]; - let expires_at = - u32::from_be_bytes([key_data[1], key_data[2], key_data[3], key_data[4]]) as u64; - let key_size = key_data[5] as usize; - - if key_data.len() < 6 + key_size { - return Err("Invalid key data length".to_string()); - } - - let mut proof_key = [0u8; 32]; - if key_size == 32 { - proof_key.copy_from_slice(&key_data[6..38]); - } else { - return Err(format!("Unsupported key size: {}", key_size)); - } - - Ok(Self { - identity_id, - proof_key, - account_reference, - expires_at, - }) - } -} - -/// Generate an auto-accept proof for QR code sharing -/// -/// According to DIP-0015, the autoAcceptProof is a signature that allows the recipient -/// to automatically accept the contact request and send one back without user interaction. -pub async fn generate_auto_accept_proof( - identity: &QualifiedIdentity, - account_reference: u32, - validity_hours: u32, -) -> Result { - use crate::backend_task::dashpay::errors::DashPayError; - - // Calculate expiration timestamp - let expires_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|_| DashPayError::SystemClockInvalid)? - .as_secs() - + (validity_hours as u64 * 3600); - - // Get wallet seed for HD derivation - use ENCRYPTION key (ECDSA_SECP256K1) as per DIP-15 - // The auto-accept proof uses HD derivation from the wallet, and ENCRYPTION keys are ECDSA_SECP256K1 - let signing_key = identity - .identity - .get_first_public_key_matching( - Purpose::ENCRYPTION, - HashSet::from([SecurityLevel::MEDIUM]), - HashSet::from([KeyType::ECDSA_SECP256K1]), - false, - ) - .ok_or(DashPayError::MissingEncryptionKey)?; - - // Resolve the ENCRYPTION private key through the JIT chokepoint — no - // parked-seed read. - let wallet_seed = identity - .resolve_private_key_bytes(signing_key) - .await? - .map(|(_, private_key)| private_key) - .ok_or(TaskError::WalletLocked)?; - - // Determine network from the identity - let network = identity.network; - - // Derive the auto-accept key using DIP-0015 path: m/9'/coin'/16'/timestamp' - // Using expiration timestamp as the derivation index - let auto_accept_xprv = derive_auto_accept_key( - &wallet_seed[..], - network, - expires_at as u32, // Truncate to u32 for derivation - ) - .map_err(|e| TaskError::DashPay(DashPayError::from(e)))?; - - // Extract the private key bytes (32 bytes) - let proof_key = *auto_accept_xprv.private_key.to_bytes(); - - Ok(AutoAcceptProofData { - identity_id: identity.identity.id(), - proof_key, - account_reference, - expires_at, - }) -} - -/// Create the autoAcceptProof bytes for inclusion in a contact request -/// -/// Format according to DIP-0015: -/// - key type (1 byte) -/// - key index (4 bytes) - the timestamp used for derivation -/// - signature size (1 byte) -/// - signature (32-96 bytes) -pub fn create_auto_accept_proof_bytes_with_key( - expires_at: u64, - signing_key_bytes: &[u8; 32], - sender_id: &Identifier, - recipient_id: &Identifier, - account_reference: u32, -) -> Result, String> { - // Derive the auto-accept key - // Sign using the provided ephemeral key from the QR - - // Create the message to sign: ownerId + toUserId + accountReference - let mut message_data = Vec::new(); - message_data.extend_from_slice(&sender_id.to_buffer()); - message_data.extend_from_slice(&recipient_id.to_buffer()); - message_data.extend_from_slice(&account_reference.to_le_bytes()); - - // Hash the message - let mut hasher = Sha256::new(); - hasher.update(&message_data); - let message_hash = hasher.finalize(); - - // Sign the digest. The key erases itself when it drops. The signature is - // low-S with a low R ground as Dash Core grinds it; any ECDSA verifier - // accepts it. - let secret_key = EcdsaSecretKey::from_bytes(signing_key_bytes) - .map_err(|e| format!("Failed to create secret key: {}", e))?; - - let sig_bytes = secret_key.sign(&message_hash.into()).to_bytes(); - - // Build the proof bytes - let mut proof_bytes = Vec::new(); - proof_bytes.push(0u8); // Key type 0 for ECDSA_SECP256K1 - proof_bytes.extend_from_slice(&(expires_at as u32).to_be_bytes()); // Key index (timestamp) - proof_bytes.push(sig_bytes.len() as u8); // Signature size - proof_bytes.extend_from_slice(&sig_bytes); // The signature - - Ok(proof_bytes) -} - -/// Verify an auto-accept proof from a contact request -/// -/// This would be called when receiving a contact request with an autoAcceptProof field -/// to determine if we should automatically accept and reciprocate. -pub async fn verify_auto_accept_proof( - proof_data: &[u8], - sender_identity_id: Identifier, - recipient_identity_id: Identifier, - our_identity: &QualifiedIdentity, - account_reference: u32, -) -> Result { - // Parse: key type (1) | key index/timestamp (4) | sig size (1) | signature - if proof_data.len() < 6 { - return Ok(false); - } - let _key_type = proof_data[0]; - let key_index = - u32::from_be_bytes([proof_data[1], proof_data[2], proof_data[3], proof_data[4]]); - let sig_len = proof_data[5] as usize; - // Compact ECDSA signatures are exactly 64 bytes - if sig_len != 64 { - return Ok(false); - } - if proof_data.len() < 6 + sig_len { - return Ok(false); - } - let signature_bytes = &proof_data[6..6 + sig_len]; - - // Expiry check - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| format!("Time error: {}", e))? - .as_secs(); - if now > key_index as u64 { - return Ok(false); - } - - // Message: ownerId + toUserId + accountReference - let mut message_data = Vec::new(); - message_data.extend_from_slice(&sender_identity_id.to_buffer()); - message_data.extend_from_slice(&recipient_identity_id.to_buffer()); - message_data.extend_from_slice(&account_reference.to_le_bytes()); - let mut hasher = Sha256::new(); - hasher.update(&message_data); - let message_hash = hasher.finalize(); - let message = Message::from_digest(message_hash.into()); - - // Derive expected pubkey from our seed and key index (timestamp) - // Use ENCRYPTION key (ECDSA_SECP256K1) for HD derivation as per DIP-15 - let signing_key = our_identity - .identity - .get_first_public_key_matching( - Purpose::ENCRYPTION, - HashSet::from([SecurityLevel::MEDIUM]), - HashSet::from([KeyType::ECDSA_SECP256K1]), - false, - ) - .ok_or("No suitable key found. This operation requires a MEDIUM security level ECDSA_SECP256K1 ENCRYPTION key.")?; - // Resolve the ENCRYPTION private key through the JIT chokepoint — no - // parked-seed read. - let wallet_seed = our_identity - .resolve_private_key_bytes(signing_key) - .await - .map_err(|e| format!("Error resolving private key: {}", e))? - .map(|(_, private_key)| private_key) - .ok_or("Private key not found")?; - let xprv = derive_auto_accept_key(&wallet_seed[..], our_identity.network, key_index) - .map_err(|e| format!("Failed to derive auto-accept key: {}", e))?; - let pubkey = dash_sdk::dpp::secp256k1::PublicKey::from(xprv.private_key.public_key()); - let sig = dash_sdk::dpp::secp256k1::ecdsa::Signature::from_compact(signature_bytes) - .map_err(|e| format!("Invalid signature bytes: {}", e))?; - - match dash_sdk::dpp::secp256k1::ecdsa::verify(&sig, message, &pubkey) { - Ok(_) => Ok(true), - Err(_) => Ok(false), - } -} - -// No local persistence required - -#[cfg(test)] -mod tests { - use super::*; - use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey, ecdsa}; - - /// The proof's signature verifies, the way `verify_auto_accept_proof` - /// checks it, against the public key of the key it was signed with. The - /// signature is low-S with a low R ground as Dash Core grinds it; R lands - /// low half the time on its own, so this signs for many accounts. - #[test] - fn proof_signature_verifies_and_has_low_r() { - let key_bytes = crate::test_support::keys::secret_bytes(); - let public_key = - PublicKey::from_secret_key(&SecretKey::from_secret_bytes(key_bytes).expect("valid")); - let sender = Identifier::from([0x01; 32]); - let recipient = Identifier::from([0x02; 32]); - let expires_at = 1_900_000_000u64; - - for account_reference in 0..32u32 { - let proof = create_auto_accept_proof_bytes_with_key( - expires_at, - &key_bytes, - &sender, - &recipient, - account_reference, - ) - .expect("proof"); - - assert_eq!(proof[0], 0, "key type"); - assert_eq!( - &proof[1..5], - &(expires_at as u32).to_be_bytes(), - "key index" - ); - assert_eq!(proof[5], 64, "signature size"); - assert_eq!(proof.len(), 6 + 64); - - let mut message_data = Vec::new(); - message_data.extend_from_slice(&sender.to_buffer()); - message_data.extend_from_slice(&recipient.to_buffer()); - message_data.extend_from_slice(&account_reference.to_le_bytes()); - let digest: [u8; 32] = Sha256::digest(&message_data).into(); - - let signature = ecdsa::Signature::from_compact(&proof[6..]).expect("compact"); - let mut normalized = signature; - normalized.normalize_s(); - assert_eq!(normalized, signature, "high S for {account_reference}"); - assert!( - ecdsa::verify(&signature, Message::from_digest(digest), &public_key).is_ok(), - "proof does not verify for {account_reference}" - ); - assert!(proof[6] < 0x80, "R is not low for {account_reference}"); - } - } -} diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index 04ff69481..0d03c8f8b 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -5,9 +5,6 @@ use super::encryption::{ use super::errors::DashPayError; use super::validation::validate_contact_request_before_send; use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::dashpay::auto_accept_proof::{ - AutoAcceptProofData, create_auto_accept_proof_bytes_with_key, -}; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::dashpay::{ @@ -239,7 +236,7 @@ pub async fn send_contact_request_with_proof( signing_key: IdentityPublicKey, to_username_or_id: String, account_label: Option, - qr_auto_accept: Option, + qr_auto_accept: Option, ) -> Result { if let Some(label) = account_label.as_deref() && let Err(error) = crate::model::dashpay::validate_account_label(label) @@ -492,23 +489,21 @@ pub async fn send_contact_request_with_proof( ); } - // If QR auto-accept data is provided, create the proof bytes now to match the final accountReference + // A scanned contact QR code signs the auto-accept proof over this + // request's final accountReference. The recipient was resolved from the + // code's own username, and the proof binds it, the sender and the account + // reference, so it is useless for any other request. if let Some(qr) = qr_auto_accept { - // Ensure the QR target matches the resolved recipient - if qr.identity_id != to_identity_id { - return Err(DashPayError::InvalidQrCode { - reason: "QR code target identity does not match recipient".to_string(), - } - .into()); - } - let proof = create_auto_accept_proof_bytes_with_key( - qr.expires_at, - &qr.proof_key, + let now_secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| DashPayError::SystemClockInvalid)? + .as_secs(); + let proof = qr.sign_proof( &identity.identity.id(), &to_identity_id, account_reference, - ) - .map_err(|e| TaskError::EncryptionError { detail: e })?; + now_secs, + )?; tracing::debug!( "Including autoAcceptProof in contact request ({} bytes)", proof.len() @@ -693,16 +688,8 @@ pub async fn accept_contact_request( }); } - // Get an AUTHENTICATION key for signing the state transition - // Platform requires CRITICAL or HIGH security level for document creation let signing_key = identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL, SecurityLevel::HIGH]), - KeyType::all_key_types().into(), - false, - ) + .dashpay_signing_key(&app_context.dashpay_contract(), "contactRequest") .ok_or_else(|| TaskError::DashPay(DashPayError::MissingAuthenticationKey))? .clone(); diff --git a/src/backend_task/dashpay/errors.rs b/src/backend_task/dashpay/errors.rs index a8561ed0c..ee343863f 100644 --- a/src/backend_task/dashpay/errors.rs +++ b/src/backend_task/dashpay/errors.rs @@ -66,13 +66,6 @@ pub enum DashPayError { #[error("The contact request could not be verified. Please check the details and try again.")] ValidationFailed { errors: Vec }, - // Auto Accept Proof Errors - #[error("The QR code format is not recognized. Please scan a valid contact QR code.")] - InvalidQrCode { reason: String }, - - #[error("This QR code has expired. Please ask for a new one.")] - QrCodeExpired { expired_at: u64, current_time: u64 }, - // Network/SDK Errors #[error("Network connection failed. Please check your internet connection and retry.")] NetworkError, @@ -177,8 +170,6 @@ impl DashPayError { matches!( self, DashPayError::UsernameResolutionFailed { .. } - | DashPayError::InvalidQrCode { .. } - | DashPayError::QrCodeExpired { .. } | DashPayError::ValidationFailed { .. } | DashPayError::AccountLabelTooLong { .. } | DashPayError::InvalidUsername { .. } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index e2f503b49..0eeb0ac02 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -5,6 +5,7 @@ use crate::model::asset_lock::validate_asset_lock_minimum; use crate::model::fee_estimation::{format_credits_as_dash, format_duffs_as_dash}; +use crate::model::qualified_identity::IdentityType; use crate::wallet_backend::platform_compatibility::StorageFailure; use dash_sdk::Error as SdkError; use dash_sdk::dapi_client::DapiClientError; @@ -14,10 +15,12 @@ use dash_sdk::dashcore_rpc; use dash_sdk::dpp::ProtocolError; use dash_sdk::dpp::consensus::ConsensusError; use dash_sdk::dpp::consensus::basic::basic_error::BasicError; +use dash_sdk::dpp::consensus::signature::SignatureError; use dash_sdk::dpp::consensus::state::state_error::StateError; use dash_sdk::dpp::dashcore; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::fee::Credits; +use dash_sdk::dpp::identity::KeyID; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; use platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock; @@ -1810,6 +1813,16 @@ pub enum TaskError { )] NoUsableWithdrawalKey, + /// No active key this app can sign with was available for a masternode or + /// evonode withdrawal, which may be signed with the payout (transfer) key + /// or, failing that, the owner key — the chosen key may have been disabled, + /// or neither is loaded for this identity. See + /// [`TaskError::no_usable_withdrawal_key`]. + #[error( + "This withdrawal can't be signed because no active payout or owner key for this identity is available in this app. Load an active payout or owner key for this identity and try again." + )] + NoUsableNodeWithdrawalKey, + /// No active key this app can sign with was available for the transfer — /// the chosen key may have been disabled, or no transfer key is loaded for /// this identity. @@ -2580,6 +2593,37 @@ pub enum TaskError { )] ContactWalletSeedUnavailable, + /// The scanned text is not a DashPay contact QR code (DIP-15 + /// `dash:?du=…&dapk=…`), as upstream platform-wallet's codecs read it. + #[error( + "This is not a DashPay contact QR code. Scan your contact's QR code again, or ask them for a new one." + )] + ContactQrInvalid { + #[source] + source: std::sync::Arc, + }, + + /// The scanned contact QR code's auto-accept key has expired. + #[error("This contact QR code has expired. Ask your contact for a new one.")] + ContactQrExpired, + + /// The identity has no wallet on this device whose recovery phrase can + /// derive its contact QR code's auto-accept key. + #[error( + "This identity's wallet is not on this device, so it cannot create a contact QR code. Import the wallet that created the identity and try again." + )] + ContactQrWalletMissing, + + /// Upstream platform-wallet could not build the contact QR code, for + /// example because the identity has no username to put in it. + #[error( + "The contact QR code could not be created. Make sure this identity has a username, then try again." + )] + ContactQrBuildFailed { + #[source] + source: std::sync::Arc, + }, + /// Deriving the per-contact encryption keys from the wallet's recovery /// phrase failed. The seam already proved the seed is present, so this is a /// derivation-math failure rather than a missing wallet. @@ -2616,6 +2660,66 @@ pub enum TaskError { source_error: Box, }, + // ────────────────────────────────────────────────────────────────────────── + // Signing key refusals (protocol version 14) + // ────────────────────────────────────────────────────────────────────────── + /// The signing key's expiry time has passed. + #[error( + "The key used to sign this action expired and can no longer sign. Choose a different key, or add a new key to this identity, then try again." + )] + SigningKeyExpired { + key_id: KeyID, + /// When the key expired, in milliseconds since the Unix epoch. + expired_at_ms: u64, + #[source] + source_error: Box, + }, + + /// The signing key has spent its whole budget. + #[error( + "The key used to sign this action has used up its spending limit and can no longer sign. Raise the key's spending limit or choose a different key, then try again." + )] + SigningKeyBudgetExhausted { + key_id: KeyID, + #[source] + source_error: Box, + }, + + /// The action costs more than the signing key's remaining budget. + #[error( + "This action costs {required_dash}, but the key used to sign it has only {remaining_dash} of its spending limit left. Raise the key's spending limit or choose a different key, then try again.", + required_dash = format_credits_as_dash(*.required_budget), + remaining_dash = format_credits_as_dash(*.remaining_budget) + )] + SigningKeyBudgetExceeded { + key_id: KeyID, + remaining_budget: Credits, + required_budget: Credits, + #[source] + source_error: Box, + }, + + /// A contract-bound key signed something outside its contract bounds. + #[error( + "The key used to sign this action is limited to other contracts or documents, so it cannot sign this one. Choose a different key, then try again." + )] + SigningKeyOutOfContractBounds { + key_id: KeyID, + #[source] + source_error: Box, + }, + + /// A contract-bound key signed an action that is not a document or token + /// operation. + #[error( + "The key used to sign this action is limited to one contract, so it cannot sign this kind of action. Choose a key that is not limited to a contract, then try again." + )] + SigningKeyContractBoundForNonBatch { + key_id: KeyID, + #[source] + source_error: Box, + }, + // ────────────────────────────────────────────────────────────────────────── // Document action fees, moderation and gas sponsorship (protocol version 14) // ────────────────────────────────────────────────────────────────────────── @@ -4283,6 +4387,20 @@ impl From for TaskError { } impl TaskError { + /// The error for a withdrawal from an identity of `identity_type` that no + /// usable key can sign. A masternode or evonode may sign with its owner key + /// as well as its payout key, so it gets + /// [`TaskError::NoUsableNodeWithdrawalKey`], which names both; a user + /// identity gets [`TaskError::NoUsableWithdrawalKey`]. + pub fn no_usable_withdrawal_key(identity_type: IdentityType) -> Self { + match identity_type { + IdentityType::User => TaskError::NoUsableWithdrawalKey, + IdentityType::Masternode | IdentityType::Evonode => { + TaskError::NoUsableNodeWithdrawalKey + } + } + } + /// The error for a token operation the wallet runtime reported as failed /// (`PlatformWalletError::TokenOperationFailed`). /// @@ -4626,6 +4744,61 @@ impl From for TaskError { source_error, })) } + ConsensusError::SignatureError(SignatureError::PublicKeyExpiredError(e)) => { + let (key_id, expired_at_ms) = (e.public_key_id(), e.expires_at()); + Some(Box::new(move |source_error| TaskError::SigningKeyExpired { + key_id, + expired_at_ms, + source_error, + })) + } + ConsensusError::SignatureError( + SignatureError::PublicKeyBudgetExhaustedError(e), + ) => { + let key_id = e.public_key_id(); + Some(Box::new(move |source_error| { + TaskError::SigningKeyBudgetExhausted { + key_id, + source_error, + } + })) + } + ConsensusError::StateError( + StateError::IdentityPublicKeyBudgetExceededError(e), + ) => { + let (key_id, remaining_budget, required_budget) = + (e.public_key_id(), e.remaining_budget(), e.required_budget()); + Some(Box::new(move |source_error| { + TaskError::SigningKeyBudgetExceeded { + key_id, + remaining_budget, + required_budget, + source_error, + } + })) + } + ConsensusError::SignatureError( + SignatureError::ContractBoundedKeyOutOfBoundsError(e), + ) => { + let key_id = *e.public_key_id(); + Some(Box::new(move |source_error| { + TaskError::SigningKeyOutOfContractBounds { + key_id, + source_error, + } + })) + } + ConsensusError::SignatureError( + SignatureError::ContractBoundedKeyNonBatchError(e), + ) => { + let key_id = *e.public_key_id(); + Some(Box::new(move |source_error| { + TaskError::SigningKeyContractBoundForNonBatch { + key_id, + source_error, + } + })) + } ConsensusError::StateError(StateError::ContractUserBannedError(e)) => { let (contract_id, identity_id) = (e.contract_id(), e.identity_id()); Some(Box::new(move |source_error| { @@ -7214,14 +7387,18 @@ mod tests { }) } - /// The protocol-version-14 refusals of contract and document authoring map - /// to their own variants, keep the SDK error as their source, and read as - /// complete sentences free of protocol jargon. + /// The protocol-version-14 refusals of signing keys and of contract and + /// document authoring map to their own variants, keep the SDK error as + /// their source, and read as complete sentences free of protocol jargon. #[test] fn protocol_v14_consensus_errors_map_to_their_variants() { use dash_sdk::dpp::consensus::basic::contract_moderation::DocumentActionFeesWithoutModerationError; use dash_sdk::dpp::consensus::basic::data_contract::PreProgrammedDistributionAmountOverLimitError; use dash_sdk::dpp::consensus::basic::token::InvalidTokenOncePerIdentityDistributionAmountError; + use dash_sdk::dpp::consensus::signature::{ + ContractBoundedKeyNonBatchError, ContractBoundedKeyOutOfBoundsError, + PublicKeyBudgetExhaustedError, PublicKeyExpiredError, + }; use dash_sdk::dpp::consensus::state::contract_moderation::{ ContractUserBannedError, ContractUserSuspendedError, }; @@ -7230,6 +7407,7 @@ mod tests { referenced_document_type_not_deletable_error::ReferencedDocumentTypeNotDeletableError, }; use dash_sdk::dpp::consensus::state::identity::gas_sponsor_insufficient_balance_error::GasSponsorInsufficientBalanceError; + use dash_sdk::dpp::consensus::state::identity::identity_public_key_budget_exceeded_error::IdentityPublicKeyBudgetExceededError; use dash_sdk::dpp::consensus::state::token::{ GasFeesPaidByNotAllowedError, InconsistentGasFeesPaidByInBatchError, }; @@ -7238,6 +7416,45 @@ mod tests { let (contract_id, identity_id) = (Identifier::random(), Identifier::random()); type Expectation = fn(&TaskError) -> bool; let cases: Vec<(ConsensusError, Expectation)> = vec![ + (PublicKeyExpiredError::new(3, 1_000, 2_000).into(), |e| { + matches!( + e, + TaskError::SigningKeyExpired { + key_id: 3, + expired_at_ms: 1_000, + .. + } + ) + }), + (PublicKeyBudgetExhaustedError::new(4).into(), |e| { + matches!(e, TaskError::SigningKeyBudgetExhausted { key_id: 4, .. }) + }), + ( + IdentityPublicKeyBudgetExceededError::new(identity_id, 5, 10, 20).into(), + |e| { + matches!( + e, + TaskError::SigningKeyBudgetExceeded { + key_id: 5, + remaining_budget: 10, + required_budget: 20, + .. + } + ) + }, + ), + (ContractBoundedKeyOutOfBoundsError::new(6).into(), |e| { + matches!( + e, + TaskError::SigningKeyOutOfContractBounds { key_id: 6, .. } + ) + }), + (ContractBoundedKeyNonBatchError::new(7).into(), |e| { + matches!( + e, + TaskError::SigningKeyContractBoundForNonBatch { key_id: 7, .. } + ) + }), ( ContractUserBannedError::new(contract_id, identity_id).into(), |e| matches!(e, TaskError::ContractUserBanned { .. }), @@ -7454,6 +7671,19 @@ mod tests { } } + #[test] + fn budget_exceeded_names_both_amounts_in_dash() { + let error = TaskError::SigningKeyBudgetExceeded { + key_id: 1, + remaining_budget: 50_000_000_000, + required_budget: 100_000_000_000, + source_error: Box::new(SdkError::Generic("budget".to_string())), + }; + let message = error.to_string(); + assert!(message.contains("costs 1 DASH"), "got: {message}"); + assert!(message.contains("only 0.5 DASH"), "got: {message}"); + } + #[test] fn token_not_transferable_names_the_token() { let token_id = Identifier::random(); @@ -8263,4 +8493,27 @@ mod tests { ); } } + + /// A masternode or evonode withdrawal may also be signed with the owner + /// key, so its "no usable key" message must name both remedies; a user + /// identity, which can only use a transfer key, keeps its own message. + #[test] + fn no_usable_withdrawal_key_names_the_purposes_the_identity_type_allows() { + assert!(matches!( + TaskError::no_usable_withdrawal_key(IdentityType::User), + TaskError::NoUsableWithdrawalKey + )); + for node in [IdentityType::Masternode, IdentityType::Evonode] { + let error = TaskError::no_usable_withdrawal_key(node); + assert!( + matches!(error, TaskError::NoUsableNodeWithdrawalKey), + "{node:?} must get the node message, got {error:?}" + ); + let msg = error.to_string(); + assert!( + msg.contains("payout") && msg.contains("owner"), + "the node message must name both usable keys, got: {msg}" + ); + } + } } diff --git a/src/backend_task/identity/add_key_to_identity.rs b/src/backend_task/identity/add_key_to_identity.rs index 5d01adfe4..47e0cbe45 100644 --- a/src/backend_task/identity/add_key_to_identity.rs +++ b/src/backend_task/identity/add_key_to_identity.rs @@ -198,16 +198,18 @@ impl AppContext { let new_identity_nonce = sdk .get_identity_nonce(qualified_identity.identity.id(), true, None) .await?; - let Some(master_key) = qualified_identity.can_sign_with_master_key() else { - return Err(TaskError::MasterKeyNotFound); - }; - let master_key_id = master_key.identity_public_key.id(); let (identity, fetched) = Identity::fetch_with_metadata(sdk, qualified_identity.identity.id(), None).await?; let identity = identity.ok_or(TaskError::IdentityNotFoundLocally)?; // The fetched balance stands until the update's proof reports a newer one. let mut balance_provenance = BalanceProvenance::ObservedAt(fetched.height); qualified_identity.identity = identity; + // Picked from the keys as just fetched, so a master disabled on the + // network since the last refresh is passed over. + let master_key_id = qualified_identity + .identity_update_signing_key() + .map(|key| key.id()) + .ok_or(TaskError::MasterKeyNotFound)?; qualified_identity.identity.bump_revision(); let assigned_key_id = qualified_identity.identity.get_public_key_max_id() + 1; if let NewKeyMaterial::Derived { expected_key_id } = material { diff --git a/src/backend_task/identity/register_dpns_name.rs b/src/backend_task/identity/register_dpns_name.rs index 5ef83a83e..735a675bb 100644 --- a/src/backend_task/identity/register_dpns_name.rs +++ b/src/backend_task/identity/register_dpns_name.rs @@ -34,7 +34,7 @@ use dash_sdk::{ use super::refresh_loaded_identities_dpns_names::name_acquired_at; use super::{BackendTaskSuccessResult, RegisterDpnsNameInput}; -use crate::model::dpns_usernames::{dpns_signing_requirement, key_can_sign_documents}; +use crate::model::dpns_usernames::{chosen_key_can_sign_documents, dpns_signing_requirement}; fn rebrand_dpns_domain_conflict(error: TaskError) -> TaskError { match error { @@ -179,7 +179,7 @@ impl AppContext { let qualified_identity = input.qualified_identity; - let preorder_document_type = dpns_contract + dpns_contract .document_type_for_name("preorder") .map_err(|_| TaskError::DataContractNotFound)?; let domain_document_type = dpns_contract @@ -223,10 +223,15 @@ impl AppContext { Some(key_id) => qualified_identity .identity .get_public_key_by_id(key_id) + // A key the user chose may have expired: the chooser offers + // it with a warning, and Platform decides by network time. .filter(|key| { - key_can_sign_documents(key, dpns_signing_requirement(&self.dpns_contract())) + chosen_key_can_sign_documents( + key, + dpns_signing_requirement(&self.dpns_contract()), + ) }), - None => qualified_identity.document_signing_key(&preorder_document_type), + None => qualified_identity.username_registration_key(&dpns_contract), } .ok_or(TaskError::NoDocumentSigningKey)?; diff --git a/src/backend_task/identity/withdraw_from_identity.rs b/src/backend_task/identity/withdraw_from_identity.rs index 1aea7949b..6e010b090 100644 --- a/src/backend_task/identity/withdraw_from_identity.rs +++ b/src/backend_task/identity/withdraw_from_identity.rs @@ -60,7 +60,7 @@ impl AppContext { // fall back to an OWNER key and bypass the owner-address policy. let signing_key_id = qualified_identity .resolve_withdrawal_signing_key(id) - .map_err(|_| TaskError::NoUsableWithdrawalKey)?; + .map_err(|_| TaskError::no_usable_withdrawal_key(qualified_identity.identity_type))?; let signing_key = qualified_identity .identity .get_public_key_by_id(signing_key_id); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index f02bde1b2..f5e8f2b8c 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -2819,9 +2819,8 @@ mod tests { /// its stored balance is read back after the task; reads name nobody. #[test] fn paid_tasks_name_the_identity_that_pays_for_them() { - use crate::backend_task::dashpay::auto_accept_proof::AutoAcceptProofData; use crate::backend_task::tokens::TokenContractParams; - use crate::model::dashpay::{ContactInfoUpdate, UnreadableContactInfoPolicy}; + use crate::model::dashpay::{ContactInfoUpdate, ContactQr, UnreadableContactInfoPolicy}; use crate::model::qualified_contract::QualifiedContract; use crate::ui::tokens::tokens_screen::IdentityTokenInfo; use dash_sdk::dpp::data_contract::TokenConfiguration; @@ -3082,13 +3081,16 @@ mod tests { dashpay(DashPayTask::SendContactRequestWithProof { identity: payer.clone(), signing_key: key.clone(), - to_identity_id: other, - account_label: None, - qr_auto_accept: AutoAcceptProofData { - identity_id: other, - proof_key: [0; 32], - account_reference: 0, - expires_at: 0, + qr: { + use platform_wallet::wallet::identity::crypto::auto_accept::{ + encode_auto_accept_key_blob, encode_dashpay_contact_uri, + }; + let key = crate::test_support::keys::secret_key(); + let uri = encode_dashpay_contact_uri( + "bob", + &encode_auto_accept_key_blob(&key, u32::MAX), + ); + ContactQr::parse(&uri, 0).expect("a contact QR code") }, }), dashpay(DashPayTask::AcceptContactRequest { diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 322f432fa..b124cc243 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1261,6 +1261,23 @@ impl AppContext { .map(|stored| stored.qi_bytes)) } + /// The wallet whose seed derives `identity`'s contact QR code key: the + /// owner its stored record names, if loaded with the identity. + /// + /// # Errors + /// + /// [`TaskError::ContactQrWalletMissing`] when no owner is recorded or it + /// is not on this device; the store's error when the record cannot be read. + pub(crate) fn contact_qr_wallet_seed_hash( + &self, + identity: &QualifiedIdentity, + ) -> std::result::Result { + self.stored_identity_wallet_link(&identity.identity.id())? + .map(|(seed_hash, _)| seed_hash) + .filter(|seed_hash| identity.associated_wallets.contains_key(seed_hash)) + .ok_or(TaskError::ContactQrWalletMissing) + } + /// The wallet link recorded for `id`, or `None` when the identity is not /// stored or was never linked to a wallet. /// diff --git a/src/context/mod.rs b/src/context/mod.rs index 01d8501fe..85fe92c46 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1640,14 +1640,24 @@ impl AppContext { self.selected_single_key_hash.lock().ok().and_then(|g| *g) } - /// Stored owner, falling back to the signing-key wallet for legacy records. + /// Stored owner, falling back for legacy records to the wallet of the + /// identity's username signing key, then to the loaded wallet that derives + /// any of its keys, whatever the key is bound to. fn owning_wallet_hash(&self, id: Identifier) -> Option { if let Ok(Some((hash, _))) = self.stored_identity_wallet_link(&id) { return Some(hash); } let identities = self.load_local_qualified_identities().ok()?; let qi = identities.into_iter().find(|qi| qi.identity.id() == id)?; - let wallet = crate::ui::identity::get_selected_wallet(&qi, Some(self), None).ok()??; + let wallet = crate::ui::identity::get_selected_wallet(&qi, Some(self), None) + .ok() + .flatten() + .or_else(|| { + qi.identity + .public_keys() + .values() + .find_map(|key| crate::ui::identity::wallet_for_signing_key(&qi, Some(key))) + })?; let hash = wallet.read().ok()?.seed_hash(); Some(hash) } @@ -2749,4 +2759,65 @@ mod tests { assert_eq!(maximum.load(Ordering::SeqCst), MAX_CONCURRENT_DPNS_VOTERS); } + + /// Regression: the owner of an identity stored without a wallet link was + /// read from its DPNS signing key, so an identity whose keys are all bound + /// to another contract had no owner. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_unlinked_identity_is_owned_by_the_wallet_deriving_its_keys() { + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::wired_test_app_context(dir.path()).await; + let (identity, wallet) = crate::context::test_support::identity_with_bound_wallet_key( + ContractBounds::SingleContract { + id: ctx.dashpay_contract().id(), + }, + ); + let seed_hash = wallet.read().expect("wallet").seed_hash(); + ctx.wallet_context().insert_test_wallet(seed_hash, wallet); + ctx.insert_local_qualified_identity(&identity, &None) + .expect("store identity"); + + assert_eq!( + ctx.owning_wallet_hash(identity.identity.id()), + Some(seed_hash) + ); + } + + /// Regression: an unlinked identity whose keys come from two wallets was + /// owned by the wallet of its lowest key id, no longer by the wallet of + /// its DPNS signing key as before, so Add Identity could preselect the + /// other wallet. The signing-key wallet comes first again. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_unlinked_identity_is_owned_by_its_signing_key_wallet_first() { + use crate::context::test_support::{auth_key, hold_as_wallet_reference}; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::wired_test_app_context(dir.path()).await; + // Key 1, bound to DashPay, from wallet A; key 2, the DPNS signing + // key, from wallet B. + let (mut identity, wallet_a) = crate::context::test_support::identity_with_bound_wallet_key( + ContractBounds::SingleContract { + id: ctx.dashpay_contract().id(), + }, + ); + let wallet_b = + crate::model::wallet::test_support::open_wallet_on(crate::test_support::keys::seed()); + let hash_a = wallet_a.read().expect("wallet").seed_hash(); + let hash_b = wallet_b.seed_hash(); + assert_ne!(hash_a, hash_b); + let mut keys = identity.identity.public_keys().clone(); + keys.insert(2, auth_key(2, SecurityLevel::HIGH)); + identity.identity.set_public_keys(keys); + hold_as_wallet_reference(&mut identity, 2, hash_b); + ctx.wallet_context().insert_test_wallet(hash_a, wallet_a); + ctx.wallet_context() + .insert_test_wallet(hash_b, Arc::new(std::sync::RwLock::new(wallet_b))); + ctx.insert_local_qualified_identity(&identity, &None) + .expect("store identity"); + + assert_eq!(ctx.owning_wallet_hash(identity.identity.id()), Some(hash_b)); + } } diff --git a/src/context/test_support.rs b/src/context/test_support.rs index 9472aa374..6a1e8edd9 100644 --- a/src/context/test_support.rs +++ b/src/context/test_support.rs @@ -88,6 +88,8 @@ pub(crate) fn user_identity_with_signing_key( key.set_id(1); key.set_purpose(Purpose::AUTHENTICATION); key.set_security_level(SecurityLevel::HIGH); + // Username registrations are signed with ECDSA keys only. + key.set_key_type(dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1); let mut identity = bare_user_identity(id, network); identity.identity.add_public_key(key.clone()); if held { @@ -280,3 +282,149 @@ pub(crate) fn dpns_name_document(owner: Identifier, label: &str, identity: Value contract_version: None, }) } + +/// An ECDSA AUTHENTICATION key `id` at `level`, unbound and unlimited. +pub(crate) fn auth_key( + id: u32, + level: dash_sdk::dpp::identity::SecurityLevel, +) -> dash_sdk::platform::IdentityPublicKey { + dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0 { + id, + purpose: dash_sdk::dpp::identity::Purpose::AUTHENTICATION, + security_level: level, + contract_bounds: None, + key_type: dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1, + read_only: false, + data: vec![2; 33].into(), + disabled_at: None, + } + .into() +} + +/// `key`, a version 0 key such as [`auth_key`] builds, bound to `bounds`. +pub(crate) fn bound_to( + key: dash_sdk::platform::IdentityPublicKey, + bounds: dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds, +) -> dash_sdk::platform::IdentityPublicKey { + let dash_sdk::platform::IdentityPublicKey::V0(mut v0) = key else { + panic!("bound_to takes a version 0 key"); + }; + v0.contract_bounds = Some(bounds); + v0.into() +} + +/// A testnet user identity publishing `keys` and holding the private half of +/// each. +pub(crate) fn identity_holding( + keys: &[dash_sdk::platform::IdentityPublicKey], +) -> QualifiedIdentity { + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + + let mut qi = bare_user_identity(Identifier::new([0xD0; 32]), Network::Testnet); + qi.identity + .set_public_keys(keys.iter().map(|key| (key.id(), key.clone())).collect()); + for key in keys { + qi.private_keys + .insert_non_encrypted( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey { + identity_public_key: key.clone(), + in_wallet_at_derivation_path: None, + }, + crate::test_support::keys::labelled_secret( + "context::test_support::identity_holding", + u64::from(key.id()), + ), + ), + ) + .expect("a free slot"); + } + qi +} + +/// The DashPay system contract, whose document types require a HIGH key. +pub(crate) fn dashpay_contract() -> dash_sdk::platform::DataContract { + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + load_system_data_contract( + SystemDataContract::Dashpay, + dash_sdk::dpp::version::PlatformVersion::latest(), + ) + .expect("the DashPay contract") +} + +/// The DPNS system contract with its `preorder` and `domain` document types +/// replaced by minimal types requiring `preorder` and `domain` security, for +/// tests where the two registration documents disagree. +pub(crate) fn dpns_contract_requiring( + preorder: dash_sdk::dpp::identity::SecurityLevel, + domain: dash_sdk::dpp::identity::SecurityLevel, +) -> dash_sdk::platform::DataContract { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::data_contract::config::DataContractConfig; + use dash_sdk::dpp::data_contract::document_type::DocumentType; + use dash_sdk::dpp::platform_value::platform_value; + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + + let version = dash_sdk::dpp::version::PlatformVersion::latest(); + let mut contract = + load_system_data_contract(SystemDataContract::DPNS, version).expect("the DPNS contract"); + let config = DataContractConfig::default_for_version(version).expect("config"); + for (name, level) in [("preorder", preorder), ("domain", domain)] { + let document_type = DocumentType::try_from_schema( + contract.id(), + 1, + config.version(), + name, + platform_value!({ + "type": "object", + "signatureSecurityLevelRequirement": level as u8 as u32, + "properties": {"value": {"type": "string", "maxLength": 20_u32, "position": 0_u32}}, + "additionalProperties": false, + }), + None, + &std::collections::BTreeMap::new(), + &config, + false, + &mut Vec::new(), + version, + ) + .expect("a document type"); + contract + .document_types_mut() + .insert(name.to_string(), document_type); + } + contract +} + +/// A user identity whose only key, key 1 (an ECDSA HIGH AUTHENTICATION key +/// bound to `bounds`), is held as a reference into an open test wallet, and +/// that wallet. +pub(crate) fn identity_with_bound_wallet_key( + bounds: dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds, +) -> ( + QualifiedIdentity, + Arc>, +) { + let wallet = crate::model::wallet::test_support::open_wallet(); + let seed_hash = wallet.seed_hash(); + let key = bound_to( + auth_key(1, dash_sdk::dpp::identity::SecurityLevel::HIGH), + bounds, + ); + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let mut identity = bare_user_identity(Identifier::new([0xD1; 32]), Network::Testnet); + identity + .identity + .set_public_keys(std::collections::BTreeMap::from([(key.id(), key)])); + hold_as_wallet_reference(&mut identity, 1, seed_hash); + let wallet = Arc::new(std::sync::RwLock::new(wallet)); + identity + .associated_wallets + .insert(seed_hash, Arc::clone(&wallet)); + (identity, wallet) +} diff --git a/src/model/dashpay.rs b/src/model/dashpay.rs index 7e6f1c4e5..4393cc66d 100644 --- a/src/model/dashpay.rs +++ b/src/model/dashpay.rs @@ -6,6 +6,7 @@ use dash_sdk::platform::{Document, Identifier}; use serde::{Deserialize, Serialize}; use super::validation::{TextLengthError, validate_char_count}; +use crate::backend_task::error::TaskError; /// Independent reads used to populate an identity's contact lists. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -666,3 +667,223 @@ mod tests { assert!(validate_account_label(&"l".repeat(101)).is_err()); } } + +/// A scanned DashPay contact QR code: DIP-15 `dash:?du=&dapk=`, +/// read with upstream platform-wallet's codecs. The key is the QR owner's +/// auto-accept key, a bearer credential that expires; it signs the +/// auto-accept proof of a contact request sent to the owner. +#[derive(Clone, PartialEq, Eq)] +pub struct ContactQr { + username: String, + key_blob: zeroize::Zeroizing>, + expires_at: u32, +} + +impl std::fmt::Debug for ContactQr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ContactQr") + .field("username", &self.username) + .field("expires_at", &self.expires_at) + .finish_non_exhaustive() + } +} + +impl ContactQr { + /// Reads `uri` as a contact QR code that is still valid at `now_secs` + /// (Unix seconds). + /// + /// # Errors + /// + /// [`TaskError::ContactQrInvalid`] when `uri` is not a DIP-15 contact QR + /// code, [`TaskError::ContactQrExpired`] when its key has expired. + pub fn parse(uri: &str, now_secs: u64) -> Result { + use platform_wallet::wallet::identity::crypto::auto_accept::{ + decode_auto_accept_key_blob, parse_dashpay_contact_uri, + }; + let invalid = |source| TaskError::ContactQrInvalid { + source: std::sync::Arc::new(source), + }; + let (username, key_blob) = parse_dashpay_contact_uri(uri.trim()).map_err(invalid)?; + let key_blob = zeroize::Zeroizing::new(key_blob); + let (_, expires_at) = decode_auto_accept_key_blob(&key_blob).map_err(invalid)?; + let qr = Self { + username, + key_blob, + expires_at, + }; + qr.ensure_unexpired(now_secs)?; + Ok(qr) + } + + /// Upstream's rule: a code is valid up to and including its expiry second. + fn ensure_unexpired(&self, now_secs: u64) -> Result<(), TaskError> { + if now_secs > u64::from(self.expires_at) { + return Err(TaskError::ContactQrExpired); + } + Ok(()) + } + + /// The QR owner's username, without the `.dash` suffix. + pub fn username(&self) -> &str { + &self.username + } + + /// The owner's full DPNS name, `.dash`. + pub fn dpns_name(&self) -> String { + format!("{}.dash", self.username) + } + + /// When the code stops working, in Unix seconds. + pub fn expires_at(&self) -> u32 { + self.expires_at + } + + /// The auto-accept proof for a contact request `sender` sends to the QR + /// owner `recipient` with `account_reference`, signed with the code's key + /// by upstream's `sign_auto_accept_proof`. The proof binds sender, + /// recipient and account reference, so it is useless for any other + /// request. + /// + /// # Errors + /// + /// [`TaskError::ContactQrExpired`] when the code has expired at `now_secs`. + pub fn sign_proof( + &self, + sender: &Identifier, + recipient: &Identifier, + account_reference: u32, + now_secs: u64, + ) -> Result, TaskError> { + use platform_wallet::wallet::identity::crypto::auto_accept::{ + decode_auto_accept_key_blob, sign_auto_accept_proof, + }; + self.ensure_unexpired(now_secs)?; + let (key, expires_at) = decode_auto_accept_key_blob(&self.key_blob).map_err(|source| { + TaskError::ContactQrInvalid { + source: std::sync::Arc::new(source), + } + })?; + Ok(sign_auto_accept_proof( + &key, + sender, + recipient, + account_reference, + expires_at, + )) + } +} + +#[cfg(test)] +mod contact_qr_tests { + use super::*; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::key_wallet::wallet::Wallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use platform_wallet::wallet::identity::crypto::auto_accept::{ + derive_auto_accept_private_key, encode_auto_accept_key_blob, encode_dashpay_contact_uri, + verify_auto_accept_proof, + }; + + const NETWORK: Network = Network::Testnet; + const EXPIRY: u32 = 1_900_000_000; + + /// A wallet from a seed generated for this test only. + fn owner_wallet() -> Wallet { + Wallet::from_seed_bytes( + rand::random::<[u8; 64]>(), + NETWORK, + WalletAccountCreationOptions::None, + ) + .expect("a test wallet") + } + + /// The contact QR code `owner` would hand out, as upstream builds it. + fn owner_qr(owner: &Wallet) -> String { + let key = derive_auto_accept_private_key(owner, NETWORK, EXPIRY).expect("a key"); + encode_dashpay_contact_uri("alice", &encode_auto_accept_key_blob(&key, EXPIRY)) + } + + /// Interop: a proof DET signs from an upstream-built QR code verifies with + /// upstream's verifier against the owner's wallet, and only for the + /// sender, recipient and account reference it was signed for. + #[test] + fn a_proof_from_an_upstream_qr_code_verifies_upstream() { + let owner = owner_wallet(); + let qr = ContactQr::parse(&owner_qr(&owner), u64::from(EXPIRY) - 60).expect("valid"); + assert_eq!(qr.username(), "alice"); + assert_eq!(qr.dpns_name(), "alice.dash"); + assert_eq!(qr.expires_at(), EXPIRY); + + let sender = Identifier::new([1; 32]); + let recipient = Identifier::new([2; 32]); + let proof = qr + .sign_proof(&sender, &recipient, 7, u64::from(EXPIRY) - 60) + .expect("a proof"); + let verifies = |sender: &Identifier, recipient: &Identifier, account: u32| { + verify_auto_accept_proof(&owner, NETWORK, &proof, sender, recipient, account) + .expect("verifiable") + }; + assert!(verifies(&sender, &recipient, 7)); + assert!( + !verifies(&Identifier::new([3; 32]), &recipient, 7), + "sender" + ); + assert!( + !verifies(&sender, &Identifier::new([3; 32]), 7), + "recipient" + ); + assert!(!verifies(&sender, &recipient, 8), "account reference"); + assert!( + !verify_auto_accept_proof(&owner_wallet(), NETWORK, &proof, &sender, &recipient, 7) + .expect("verifiable"), + "another wallet's key" + ); + } + + /// An expired code is refused when read and again when signing. + #[test] + fn an_expired_code_is_refused() { + let uri = owner_qr(&owner_wallet()); + assert!(matches!( + ContactQr::parse(&uri, u64::from(EXPIRY) + 1), + Err(TaskError::ContactQrExpired) + )); + let qr = ContactQr::parse(&uri, u64::from(EXPIRY)).expect("valid until its expiry"); + assert!(matches!( + qr.sign_proof( + &Identifier::new([1; 32]), + &Identifier::new([2; 32]), + 0, + u64::from(EXPIRY) + 1 + ), + Err(TaskError::ContactQrExpired) + )); + } + + /// Codes in DET's former `di=` format, and anything else that + /// is not a DIP-15 contact code, are refused as invalid. + #[test] + fn a_code_that_is_not_dip15_is_invalid() { + let former = "dash:?di=4EfA9Jrvv3nnCFdSf7fad59851iiTRZ6Wcu6YVJ4iSeF&dapk=1"; + for uri in [former, "dash:?du=alice", "bitcoin:?du=alice&dapk=1", ""] { + assert!( + matches!( + ContactQr::parse(uri, 0), + Err(TaskError::ContactQrInvalid { .. }) + ), + "{uri}" + ); + } + } + + /// The key never shows in debug output. + #[test] + fn debug_output_hides_the_key() { + let qr = ContactQr::parse(&owner_qr(&owner_wallet()), 0).expect("valid"); + let shown = format!("{qr:?}"); + assert!( + shown.contains("alice") && !shown.contains("key_blob"), + "{shown}" + ); + } +} diff --git a/src/model/dashpay_derivation/hd.rs b/src/model/dashpay_derivation/hd.rs index 54115c3ac..153b52bc4 100644 --- a/src/model/dashpay_derivation/hd.rs +++ b/src/model/dashpay_derivation/hd.rs @@ -1,17 +1,11 @@ use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::dashcore::hashes::{Hash, HashEngine}; -use dash_sdk::dpp::key_wallet::bip32::{ - ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, -}; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, ExtendedPubKey}; use dash_sdk::platform::Identifier; -use std::str::FromStr; use super::DerivationError; use super::dip14::derive_dashpay_incoming_xpub_dip14; -/// DashPay auto-accept proof feature index - use the constant from dip9 if available -const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; - /// Derive the DashPay incoming funds extended public key for a contact relationship /// Path: m/9'/coin'/15'/account'/(sender_id)/(recipient_id) /// @@ -75,30 +69,6 @@ pub fn generate_contact_xpub_data( Ok((parent_fingerprint, chain_code, public_key_bytes)) } -/// Derive auto-accept proof key according to DIP-0015 -/// Path: m/9'/coin'/16'/timestamp' -pub fn derive_auto_accept_key( - master_seed: &[u8], - network: Network, - timestamp: u32, -) -> Result { - use crate::model::wallet::coin_type_for_network; - - // Create extended private key from seed - let master_xprv = ExtendedPrivKey::new_master(network, master_seed)?; - - // Build derivation path: m/9'/coin'/16'/timestamp' - let coin_type = coin_type_for_network(network); - let path = DerivationPath::from_str(&format!( - "m/9'/{coin_type}'/{DASHPAY_AUTO_ACCEPT_FEATURE}'/{timestamp}'" - ))?; - - // Derive the key - let auto_accept_key = master_xprv.derive_priv(&path)?; - - Ok(auto_accept_key) -} - /// Calculate account reference as specified in DIP-0015 pub fn calculate_account_reference( sender_secret_key: &[u8], @@ -132,6 +102,8 @@ pub fn calculate_account_reference( #[cfg(test)] mod tests { use super::*; + use dash_sdk::dpp::key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; + use std::str::FromStr; #[test] fn test_dashpay_derivation_path() { @@ -221,40 +193,6 @@ mod tests { ); } - #[test] - fn test_auto_accept_key_derivation() { - // Test auto-accept key derivation - let network = Network::Testnet; - let master_seed = crate::test_support::keys::seed(); - let timestamp = 1700000000u32; - - let key = derive_auto_accept_key(&master_seed, network, timestamp) - .expect("Should derive auto-accept key"); - - // Verify the key was derived correctly - assert_eq!(key.network, network); - assert_eq!(key.depth, 4); // m/9'/1'/16'/timestamp' on testnet = depth 4 - } - - #[test] - fn test_auto_accept_key_different_timestamps() { - // Different timestamps should produce different keys - let network = Network::Testnet; - let master_seed = crate::test_support::keys::seed(); - - let key1 = - derive_auto_accept_key(&master_seed, network, 1700000000).expect("Should derive key 1"); - let key2 = - derive_auto_accept_key(&master_seed, network, 1700000001).expect("Should derive key 2"); - - // Different timestamps should produce different keys - assert_ne!( - key1.private_key.to_bytes(), - key2.private_key.to_bytes(), - "Different timestamps should produce different keys" - ); - } - #[test] fn test_generate_contact_xpub_data() { // Test generating contact xpub data diff --git a/src/model/dashpay_derivation/mod.rs b/src/model/dashpay_derivation/mod.rs index 5ed877885..64ad20c6a 100644 --- a/src/model/dashpay_derivation/mod.rs +++ b/src/model/dashpay_derivation/mod.rs @@ -8,8 +8,8 @@ pub mod dip14; pub mod hd; pub use hd::{ - calculate_account_reference, derive_auto_accept_key, derive_dashpay_incoming_xpub, - derive_payment_address, generate_contact_xpub_data, + calculate_account_reference, derive_dashpay_incoming_xpub, derive_payment_address, + generate_contact_xpub_data, }; use thiserror::Error; diff --git a/src/model/dpns_usernames.rs b/src/model/dpns_usernames.rs index 0552923a2..af719d921 100644 --- a/src/model/dpns_usernames.rs +++ b/src/model/dpns_usernames.rs @@ -116,18 +116,79 @@ impl SeenMarkAttempts { } } -/// Whether `key` may sign documents that require `required` security: an enabled -/// authentication key at least as strong as required, and never a master key. +/// Whether `key` may sign documents that require `required` security: a +/// [`chosen_key_can_sign_documents`] key that is also unexpired and whose +/// bounds are known to permit DPNS (never a contract-group bound key). The +/// rule for choosing a key automatically. pub fn key_can_sign_documents( key: &dash_sdk::platform::IdentityPublicKey, required: dash_sdk::dpp::identity::SecurityLevel, ) -> bool { + chosen_key_can_sign_documents(key, required) + && super::identity_key_usability::is_auto_selectable( + key, + dpns_signing_scope(), + super::identity_key_usability::now_ms(), + ) +} + +/// Whether the user may sign documents that require `required` security with +/// `key` they chose: an enabled ECDSA ([`DPNS_SIGNING_KEY_TYPES`]) +/// authentication key at least as strong as required, never a master key, and +/// not bound to another contract. +/// +/// Unlike [`key_can_sign_documents`], an expired key is allowed: the key chooser +/// offers it with a warning, and whether it has expired is Platform's call by +/// network time, not this device's clock. So is a key bound to a contract +/// group: whether the group covers DPNS is Platform state, so only a key +/// certainly bound elsewhere is refused here. +/// +/// By design, the relaxation reaches only an identity that can register at +/// all: [`registration_key_status`] applies the automatic rule +/// ([`key_can_sign_documents`]), so an identity whose only registration-capable +/// keys are expired or contract-group bound is told it has no suitable key and +/// never reaches the chooser that would offer them. +// TODO(upstream): call platform-wallet's public explicit-key validation once it +// exists (`resolve_authentication_signing_key` is private) and route the +// registration through `register_name_with_signer`. +pub fn chosen_key_can_sign_documents( + key: &dash_sdk::platform::IdentityPublicKey, + required: dash_sdk::dpp::identity::SecurityLevel, +) -> bool { + use dash_sdk::dpp::identity::Purpose; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; key.purpose() == Purpose::AUTHENTICATION + && DPNS_SIGNING_KEY_TYPES.contains(&key.key_type()) && !key.is_disabled() - && key.security_level() != SecurityLevel::MASTER - && key.security_level() <= required + && super::identity_key_usability::bounds_permit(key.contract_bounds(), dpns_signing_scope()) + != Some(false) + && dpns_security_levels(required).contains(&key.security_level()) +} + +/// The key types a username registration is signed with: ECDSA keys, as +/// upstream platform-wallet's DPNS writes require. `ECDSA_HASH160` is taken +/// too, as for DashPay ([`super::qualified_identity::DASHPAY_SIGNING_KEY_TYPES`]): +/// Platform accepts it and DET signed with it before, so dropping it would +/// strand identities whose only document key is a hash160 key. +/// +/// `BLS12_381` is left out for parity with upstream, not because Platform +/// refuses it: Platform verifies BLS signatures +/// ([`super::identity_key_usability::PLATFORM_SIGNING_KEY_TYPES`]) and DET can +/// sign with a BLS key, so an identity whose only registration-capable key is +/// a BLS key cannot register a username here. EdDSA and BIP13 keys Platform +/// refuses outright. +// TODO(upstream): take the key types from platform-wallet's public DPNS key +// selector once it exists, instead of restating them here. +pub const DPNS_SIGNING_KEY_TYPES: &[dash_sdk::dpp::identity::KeyType] = &[ + dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1, + dash_sdk::dpp::identity::KeyType::ECDSA_HASH160, +]; + +/// The scope a username registration signs in: the DPNS contract. +pub fn dpns_signing_scope() -> super::identity_key_usability::SigningScope<'static> { + super::identity_key_usability::SigningScope::ContractWide { + contract_id: dash_sdk::dpp::system_data_contracts::SystemDataContract::DPNS.id(), + } } /// The weakest key security level that may sign both DPNS documents a registration @@ -145,6 +206,28 @@ pub fn dpns_signing_requirement( .unwrap_or(dash_sdk::dpp::identity::SecurityLevel::HIGH) } +/// The key security levels that may sign documents requiring `required` +/// security in a username registration: CRITICAL up to and including +/// `required`, since a stronger key meets a weaker requirement, and never +/// MASTER. Empty when `required` is MASTER. The one source of the levels for +/// the automatic choice ([`QualifiedIdentity::username_registration_key`]), +/// the Advanced key chooser, and the check of a key the user chose +/// ([`chosen_key_can_sign_documents`]); pass [`dpns_signing_requirement`] so +/// both registration documents decide. +pub fn dpns_security_levels( + required: dash_sdk::dpp::identity::SecurityLevel, +) -> Vec { + use dash_sdk::dpp::identity::SecurityLevel; + [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .into_iter() + .filter(|level| *level <= required) + .collect() +} + /// Whether this device holds a private key that can sign username registrations /// (documents requiring `required` security) for `identity`, and can obtain it. pub fn can_register_usernames( @@ -166,7 +249,9 @@ pub fn missing_registration_wallets( .values() .filter_map(move |(key, data)| match data { PrivateKeyData::AtWalletDerivationPath(path) - if key_can_sign_documents(&key.identity_public_key, required) + if identity + .current_public_key(&key.identity_public_key) + .is_some_and(|live| key_can_sign_documents(live, required)) && !identity.key_secret_is_obtainable(data) => { Some(&path.wallet_seed_hash) @@ -183,6 +268,10 @@ pub enum RegistrationKeyStatus { /// This device holds such a key only as a reference into a wallet that is /// not on this device, so nothing here can produce its private half. KeyWalletNotOnDevice, + /// This device holds such a key only as saved by an early version, in a + /// per-key encrypted form this version cannot open; entering the key + /// again makes it usable. + KeySavedByOlderVersion, /// The identity has such a key, but this device does not hold it. KeyNotOnDevice, /// The identity has no key that can sign a registration. @@ -191,7 +280,9 @@ pub enum RegistrationKeyStatus { impl RegistrationKeyStatus { /// Whether the identity has a key that can sign a registration, but this - /// device cannot use it. + /// device cannot use it and restoring from the previous version may bring + /// it back. [`Self::KeySavedByOlderVersion`] is not among them: the restore + /// cannot open that key either, it must be entered again. pub fn key_is_unusable_here(self) -> bool { matches!(self, Self::KeyNotOnDevice | Self::KeyWalletNotOnDevice) } @@ -208,20 +299,31 @@ pub fn registration_key_status( ) -> RegistrationKeyStatus { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; let mut held_without_its_wallet = false; + let mut held_in_legacy_form = false; for (key, data) in identity.private_keys.values() { - if !key_can_sign_documents(&key.identity_public_key, required) { + // The stored copy may predate a limits update: judge the key as the + // identity holds it now, and skip one it no longer holds. + if !identity + .current_public_key(&key.identity_public_key) + .is_some_and(|live| key_can_sign_documents(live, required)) + { continue; } - // TODO(legacy-encrypted-key-gate): this version cannot open the legacy - // per-key envelope, so such a key fails at signing; it is still reported - // as ready because it needs its own status and guidance first. - if identity.key_secret_is_obtainable(data) || matches!(data, PrivateKeyData::Encrypted(_)) { + if identity.key_secret_is_obtainable(data) { return RegistrationKeyStatus::Ready; } - held_without_its_wallet = true; + // Not obtainable: kept in the legacy per-key envelope this version + // cannot open, or a reference into a wallet not on this device. + if matches!(data, PrivateKeyData::Encrypted(_)) { + held_in_legacy_form = true; + } else { + held_without_its_wallet = true; + } } if held_without_its_wallet { RegistrationKeyStatus::KeyWalletNotOnDevice + } else if held_in_legacy_form { + RegistrationKeyStatus::KeySavedByOlderVersion } else if identity .identity .public_keys() @@ -1152,6 +1254,7 @@ mod tests { dash_sdk::dpp::version::PlatformVersion::latest(), ); key.set_purpose(purpose); + key.set_key_type(dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1); key.set_security_level(level); key }; @@ -1178,6 +1281,149 @@ mod tests { )); } + /// A key the user picked stays usable after it expires: the chooser offers + /// it with a warning, and Platform decides by network time. Automatic + /// choice still skips it. The other rules hold for both. + #[test] + fn a_chosen_key_is_not_refused_for_its_expiry() { + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; + let key = |purpose, level| { + let mut key = dash_sdk::platform::IdentityPublicKey::random_key( + 1, + Some(1), + dash_sdk::dpp::version::PlatformVersion::latest(), + ); + key.set_purpose(purpose); + key.set_key_type(dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1); + key.set_security_level(level); + key.with_limits(None, Some(1)) + }; + let high = SecurityLevel::HIGH; + let expired = key(Purpose::AUTHENTICATION, high); + assert!(!key_can_sign_documents(&expired, high)); + assert!(chosen_key_can_sign_documents(&expired, high)); + + for refused in [ + key(Purpose::AUTHENTICATION, SecurityLevel::MEDIUM), + key(Purpose::AUTHENTICATION, SecurityLevel::MASTER), + key(Purpose::TRANSFER, SecurityLevel::CRITICAL), + ] { + assert!( + !chosen_key_can_sign_documents(&refused, high), + "{refused:?}" + ); + } + let mut disabled = key(Purpose::AUTHENTICATION, high); + disabled.set_disabled_at(1); + assert!(!chosen_key_can_sign_documents(&disabled, high)); + } + + /// Regression: a username registration was signed with any key type, so a + /// BLS or EdDSA key could be picked although upstream platform-wallet's + /// DPNS writes sign with ECDSA keys only, and Platform refuses EdDSA. + #[test] + fn a_username_is_registered_with_an_ecdsa_key() { + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::identity::{KeyType, SecurityLevel}; + let high = SecurityLevel::HIGH; + let typed = |key_type| { + let mut key = crate::context::test_support::auth_key(1, high); + key.set_key_type(key_type); + key + }; + for refused in [ + KeyType::BLS12_381, + KeyType::EDDSA_25519_HASH160, + KeyType::BIP13_SCRIPT_HASH, + ] { + assert!( + !key_can_sign_documents(&typed(refused), high), + "{refused:?}" + ); + assert!( + !chosen_key_can_sign_documents(&typed(refused), high), + "{refused:?}" + ); + } + for accepted in [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160] { + assert!( + key_can_sign_documents(&typed(accepted), high), + "{accepted:?}" + ); + assert!( + chosen_key_can_sign_documents(&typed(accepted), high), + "{accepted:?}" + ); + } + } + + /// Regression: a contract-group bound key the user chose was refused here, + /// though whether the group covers DPNS is only known to Platform. The + /// automatic choice still never takes one; a key bound elsewhere is + /// refused either way. + #[test] + fn a_chosen_contract_group_key_is_left_for_platform_to_judge() { + use crate::context::test_support::{auth_key, bound_to}; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let high = SecurityLevel::HIGH; + let grouped = bound_to( + auth_key(1, high), + ContractBounds::ContractGroup { id: id(7) }, + ); + assert!(chosen_key_can_sign_documents(&grouped, high)); + assert!(!key_can_sign_documents(&grouped, high)); + + let elsewhere = bound_to( + auth_key(1, high), + ContractBounds::SingleContract { id: id(9) }, + ); + assert!(!chosen_key_can_sign_documents(&elsewhere, high)); + } + + /// Regression: readiness judged the stored copy of a key, so a key whose + /// expiry Platform has since extended was reported unusable, and its + /// missing wallet was not reported as what would lift the refusal. + #[test] + fn readiness_judges_a_key_as_the_identity_holds_it_now() { + use crate::context::test_support::{auth_key, hold_as_wallet_reference, identity_holding}; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + let high = SecurityLevel::HIGH; + let stale = auth_key(1, high).with_limits(None, Some(1)); + let extended = stale.clone().with_limits(None, Some(u64::MAX)); + let extend = |identity: &mut QualifiedIdentity| { + identity + .identity + .set_public_keys(std::collections::BTreeMap::from([(1, extended.clone())])); + }; + + // The stored copy says expired, the live key does not. + let mut identity = identity_holding(std::slice::from_ref(&stale)); + assert_eq!( + registration_key_status(&identity, high), + RegistrationKeyStatus::NoSuitableKey, + "the expired key alone cannot register" + ); + extend(&mut identity); + assert_eq!( + registration_key_status(&identity, high), + RegistrationKeyStatus::Ready + ); + + // The same, for a key held as a reference into a missing wallet. + let mut identity = identity_holding(std::slice::from_ref(&stale)); + hold_as_wallet_reference(&mut identity, 1, [0x5A; 32]); + extend(&mut identity); + assert_eq!( + missing_registration_wallets(&identity, high) + .copied() + .collect::>(), + vec![[0x5A; 32]] + ); + } + /// A testnet identity publishing `published` keys and holding `held` of them. fn identity_with_keys( published: &[(u32, dash_sdk::dpp::identity::SecurityLevel)], @@ -1200,6 +1446,7 @@ mod tests { dash_sdk::dpp::version::PlatformVersion::latest(), ); key.set_id(*key_id); + key.set_key_type(dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1); key.set_purpose(Purpose::AUTHENTICATION); key.set_security_level(*level); identity.identity.add_public_key(key.clone()); @@ -1296,6 +1543,32 @@ mod tests { assert!(missing(&identity).is_empty()); } + /// Regression: a private key filed for a key the identity no longer holds + /// was judged by its stored copy, so readiness reported a key the + /// registration's key choice cannot pick, and asked for its wallet. + #[test] + fn a_filed_key_the_identity_no_longer_holds_cannot_register() { + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + let high = SecurityLevel::HIGH; + let drop_key_1 = |identity: &mut QualifiedIdentity| { + let mut keys = identity.identity.public_keys().clone(); + keys.remove(&1); + identity.identity.set_public_keys(keys); + }; + let mut identity = identity_with_keys(&[(0, SecurityLevel::MASTER), (1, high)], &[0, 1]); + drop_key_1(&mut identity); + assert_eq!( + registration_key_status(&identity, high), + RegistrationKeyStatus::NoSuitableKey + ); + + let mut identity = identity_with_keys(&[(0, SecurityLevel::MASTER), (1, high)], &[]); + crate::context::test_support::hold_as_wallet_reference(&mut identity, 1, [0x5A; 32]); + drop_key_1(&mut identity); + assert_eq!(missing_registration_wallets(&identity, high).count(), 0); + } + /// One usable key is enough: a second key stranded in a missing wallet /// does not take the identity's ability to register away. #[test] @@ -1311,6 +1584,23 @@ mod tests { ); } + /// Regression: a key kept only in the per-key encrypted format of early + /// versions was reported as ready, though this version cannot open it, so + /// the username flow offered a registration that failed at signing. + #[test] + fn a_legacy_encrypted_key_cannot_register() { + use dash_sdk::dpp::identity::SecurityLevel; + let high = SecurityLevel::HIGH; + let mut identity = identity_with_keys(&[(1, high)], &[1]); + crate::context::test_support::hold_as_legacy_encrypted(&mut identity, 1); + + assert!(!can_register_usernames(&identity, high)); + assert_eq!( + registration_key_status(&identity, high), + RegistrationKeyStatus::KeySavedByOlderVersion + ); + } + /// A reference into a missing wallet for a key that cannot register anyway /// says nothing about registration: the key the identity needs is simply /// not held. diff --git a/src/model/identity_key_usability.rs b/src/model/identity_key_usability.rs new file mode 100644 index 000000000..c9e117e4a --- /dev/null +++ b/src/model/identity_key_usability.rs @@ -0,0 +1,682 @@ +//! Which identity keys can sign a given state transition right now. +//! +//! From protocol version 14 an identity key may carry usage limits (a lifetime +//! `total_budget` and an `expires_at` instant, V1 keys only) and an +//! AUTHENTICATION key may be bound to one contract, one document type or a +//! contract group. Picking a signing key by purpose, security level and key type +//! alone can therefore choose a key Platform refuses: an expired key, a key +//! bound elsewhere, or a contract-bound key on a transition outside a batch. +//! +//! These are pure rules — the caller passes the wall clock — mirroring upstream +//! `platform-wallet`'s `key_selection`: an unlimited key is preferred over a +//! limited one, a contract-group bound key is never chosen automatically (group +//! membership is Platform state, not known offline), and what is left of a +//! budget is not known offline either, so a spent key is refused by Platform. + +use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; +use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; +use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; +use dash_sdk::dpp::identity::{ + Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel, TimestampMillis, +}; +use dash_sdk::platform::Identifier; + +/// The key types Platform verifies an identity-signed state transition's +/// signature with (drive-abci's `SUPPORTED_KEY_TYPES`), all of which DET's +/// signer signs with. Platform refuses `EDDSA_25519_HASH160` and +/// `BIP13_SCRIPT_HASH` keys, whatever the transition. +// TODO(upstream): take the set from dpp once it exposes one; drive-abci's +// is private. +pub const PLATFORM_SIGNING_KEY_TYPES: &[KeyType] = &[ + KeyType::ECDSA_SECP256K1, + KeyType::ECDSA_HASH160, + KeyType::BLS12_381, +]; + +/// What a signature authorizes, as far as contract bounds are concerned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigningScope<'a> { + /// A state transition outside a batch: identity update, credit transfer or + /// withdrawal, data contract create or update, masternode vote. Platform + /// refuses a contract-bound key on any of them. + NonBatch, + /// A document transition on `document_type_name` of `contract_id`. + Document { + contract_id: Identifier, + document_type_name: &'a str, + }, + /// Transitions spanning `contract_id` as a whole: a token transition (token + /// operations are contract-wide), or document transitions on several + /// document types of one contract (DPNS preorder + domain). A document-type + /// bound never covers them. + ContractWide { contract_id: Identifier }, +} + +/// Why a key cannot sign in a [`SigningScope`], or what the user should know +/// before choosing it. Ordered by severity: the first applicable one is shown. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeyCaveat { + /// The key is disabled; Platform refuses it. + Disabled, + /// The key's expiry has passed; Platform refuses it. + Expired { expired_at: TimestampMillis }, + /// The key is bound to a different contract or document type. + OutOfBounds, + /// The key is bound to a contract group; whether the group covers this + /// contract is only known to Platform. + ContractGroupBound { group_id: Identifier }, + /// The key has a lifetime budget; Platform refuses it once the budget is + /// spent. + Budgeted { total_budget: u64 }, + /// The key expires at `expires_at` (in the future). + Expiring { expires_at: TimestampMillis }, +} + +impl KeyCaveat { + /// Whether Platform is certain to refuse a signature from the key. + pub fn blocks_signing(&self) -> bool { + match self { + KeyCaveat::Disabled | KeyCaveat::Expired { .. } | KeyCaveat::OutOfBounds => true, + KeyCaveat::ContractGroupBound { .. } + | KeyCaveat::Budgeted { .. } + | KeyCaveat::Expiring { .. } => false, + } + } +} + +/// Whether a key carrying `bounds` may sign in `scope`, when that can be known +/// offline. `None` means only Platform can answer (a contract-group bound on a +/// batch transition). +// TODO(upstream): delegate to a dpp pre-build bounds check (dpp's public +// `ContractBounds::check_batched_transition` needs a built transition, and the +// non-batch rule lives in the private `verify_identity_key_bounds`). +pub fn bounds_permit(bounds: Option<&ContractBounds>, scope: SigningScope<'_>) -> Option { + match (bounds, scope) { + (None, _) => Some(true), + (Some(_), SigningScope::NonBatch) => Some(false), + ( + Some(ContractBounds::SingleContract { id }), + SigningScope::Document { contract_id, .. } | SigningScope::ContractWide { contract_id }, + ) => Some(*id == contract_id), + ( + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: bound_type, + }), + SigningScope::Document { + contract_id, + document_type_name, + }, + ) => Some(*id == contract_id && bound_type == document_type_name), + ( + Some(ContractBounds::SingleContractDocumentType { .. }), + SigningScope::ContractWide { .. }, + ) => Some(false), + ( + Some(ContractBounds::ContractGroup { .. }), + SigningScope::Document { .. } | SigningScope::ContractWide { .. }, + ) => None, + } +} + +/// Everything the user should know about signing with `key` in `scope` at +/// `now_ms`, most severe first. Empty for an unlimited, unbound, live key. +pub fn key_caveats( + key: &IdentityPublicKey, + scope: SigningScope<'_>, + now_ms: TimestampMillis, +) -> Vec { + let mut caveats = Vec::new(); + if key.is_disabled() { + caveats.push(KeyCaveat::Disabled); + } + if let Some(expires_at) = key.expires_at() + && key.is_expired_at(now_ms) + { + caveats.push(KeyCaveat::Expired { + expired_at: expires_at, + }); + } + match bounds_permit(key.contract_bounds(), scope) { + Some(true) => {} + Some(false) => caveats.push(KeyCaveat::OutOfBounds), + None => { + if let Some(group_id) = key.contract_bounds().and_then(|b| b.contract_group_id()) { + caveats.push(KeyCaveat::ContractGroupBound { + group_id: *group_id, + }); + } + } + } + if let Some(total_budget) = key.total_budget() { + caveats.push(KeyCaveat::Budgeted { total_budget }); + } + if let Some(expires_at) = key.expires_at() + && !key.is_expired_at(now_ms) + { + caveats.push(KeyCaveat::Expiring { expires_at }); + } + caveats +} + +/// Whether `key` may be picked automatically for `scope` at `now_ms`: not +/// disabled, not expired, and bounds known to permit the scope. +pub fn is_auto_selectable( + key: &IdentityPublicKey, + scope: SigningScope<'_>, + now_ms: TimestampMillis, +) -> bool { + !key.is_disabled() + && !key.is_expired_at(now_ms) + && bounds_permit(key.contract_bounds(), scope) == Some(true) +} + +/// The security levels Platform accepts for a document action on +/// `document_type`: CRITICAL up to and including the level the type requires, +/// since a stronger key satisfies a weaker requirement; exactly MASTER for a +/// type that requires MASTER. +// TODO(upstream): call dpp's accepted-levels helper for a requirement once it +// is public (platform-wallet's `allowed_signing_security_levels` is private). +pub fn document_security_levels(document_type: DocumentTypeRef<'_>) -> Vec { + if document_type.security_level_requirement() == SecurityLevel::MASTER { + return vec![SecurityLevel::MASTER]; + } + let required = document_type.security_level_requirement() as u8; + [ + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ] + .into_iter() + .filter(|level| (SecurityLevel::CRITICAL as u8..=required).contains(&(*level as u8))) + .collect() +} + +/// The signing key requirements of a transition. +#[derive(Debug, Clone, Copy)] +pub struct KeyRequirements<'a> { + pub purpose: Purpose, + pub security_levels: &'a [SecurityLevel], + /// `None` accepts every key type. [`Self::new`] sets + /// [`PLATFORM_SIGNING_KEY_TYPES`]. + pub key_types: Option<&'a [KeyType]>, + pub scope: SigningScope<'a>, +} + +impl KeyRequirements<'_> { + pub(crate) fn matches(&self, key: &IdentityPublicKey) -> bool { + key.purpose() == self.purpose + && self.security_levels.contains(&key.security_level()) + && self + .key_types + .is_none_or(|types| types.contains(&key.key_type())) + } +} + +/// The key of `keys` to pick automatically for `requirements` at `now_ms` +/// among those `can_sign` accepts, or `None` when none qualifies. +/// +/// Mirrors upstream platform-wallet's signer-aware `usable_authentication_key`: +/// a key qualifies when it matches purpose, security level and key type and +/// [`is_auto_selectable`]; qualifying unlimited keys come first, then limited +/// ones (a budgeted or expiring key is an application key), each in `keys` +/// order (key id order for an identity's key map); the first of that sequence +/// this device can sign with wins. So a limited key held here beats an +/// unlimited one whose private half lives elsewhere — the delegated-key case +/// protocol version 14 limits exist for. +// TODO(upstream): replace with platform-wallet's public signer-aware key +// selector once it exists (`usable_authentication_key` and `first_available` +// are crate-private). +pub fn select_signing_key<'k>( + keys: impl IntoIterator, + requirements: KeyRequirements<'_>, + now_ms: TimestampMillis, + can_sign: impl Fn(&IdentityPublicKey) -> bool, +) -> Option<&'k IdentityPublicKey> { + let qualifying: Vec<&IdentityPublicKey> = keys + .into_iter() + .filter(|key| { + requirements.matches(key) && is_auto_selectable(key, requirements.scope, now_ms) + }) + .collect(); + let unlimited = qualifying.iter().filter(|key| !key.has_limits()); + let limited = qualifying.iter().filter(|key| key.has_limits()); + unlimited.chain(limited).find(|key| can_sign(key)).copied() +} + +impl<'a> KeyRequirements<'a> { + /// Requirements accepting the key types Platform verifies signatures + /// with, [`PLATFORM_SIGNING_KEY_TYPES`]. + pub const fn new( + purpose: Purpose, + security_levels: &'a [SecurityLevel], + scope: SigningScope<'a>, + ) -> Self { + Self { + purpose, + security_levels, + key_types: Some(PLATFORM_SIGNING_KEY_TYPES), + scope, + } + } + + /// Accepts only `key_types` instead of the default. + pub const fn with_key_types(mut self, key_types: &'a [KeyType]) -> Self { + self.key_types = Some(key_types); + self + } +} + +/// [`select_signing_key`] over `identity`'s keys. +pub fn select_identity_signing_key<'i>( + identity: &'i Identity, + requirements: KeyRequirements<'_>, + now_ms: TimestampMillis, + can_sign: impl Fn(&IdentityPublicKey) -> bool, +) -> Option<&'i IdentityPublicKey> { + select_signing_key( + identity.public_keys().values(), + requirements, + now_ms, + can_sign, + ) +} + +/// The current wall-clock time in milliseconds since the Unix epoch, the clock +/// key expiry is compared against. Platform compares against block time, which +/// trails the wall clock by seconds at most. +pub fn now_ms() -> TimestampMillis { + chrono::Utc::now().timestamp_millis().max(0) as TimestampMillis +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dash_sdk::dpp::platform_value::BinaryData; + + /// Regression: a document type requiring MASTER accepted no level, as + /// MASTER sorts below CRITICAL. Platform accepts exactly MASTER for it, + /// as upstream platform-wallet's `allowed_signing_security_levels` does. + #[test] + fn a_master_document_type_accepts_only_master_keys() { + use dash_sdk::dpp::data_contract::config::DataContractConfig; + use dash_sdk::dpp::data_contract::document_type::DocumentType; + use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dash_sdk::dpp::platform_value::platform_value; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + let version = PlatformVersion::get(14).expect("protocol 14"); + let config = DataContractConfig::default_for_version(version).expect("config"); + let document_type = DocumentType::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "vault", + platform_value!({ + "type": "object", + "signatureSecurityLevelRequirement": 0_u32, + "properties": {"value": {"type": "string", "maxLength": 20_u32, "position": 0_u32}}, + "additionalProperties": false, + }), + None, + &std::collections::BTreeMap::new(), + &config, + false, + &mut Vec::new(), + version, + ) + .expect("a document type requiring MASTER"); + assert_eq!( + document_type.security_level_requirement(), + SecurityLevel::MASTER + ); + assert_eq!( + document_security_levels(document_type.as_ref()), + vec![SecurityLevel::MASTER] + ); + } + + /// A HIGH document type accepts CRITICAL and HIGH keys, never MEDIUM. + #[test] + fn a_high_document_type_accepts_critical_and_high_keys() { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + let dashpay = crate::context::test_support::dashpay_contract(); + for name in ["contactRequest", "contactInfo"] { + let document_type = dashpay.document_type_for_name(name).expect("document type"); + assert_eq!( + document_type.security_level_requirement(), + SecurityLevel::HIGH + ); + assert_eq!( + document_security_levels(document_type), + vec![SecurityLevel::CRITICAL, SecurityLevel::HIGH], + "{name}" + ); + } + } + + const CONTRACT: Identifier = Identifier::new([0xDA; 32]); + const OTHER: Identifier = Identifier::new([0x0E; 32]); + const NOW: TimestampMillis = 1_800_000_000_000; + + fn key(id: u32, bounds: Option) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: bounds, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2; 33]), + disabled_at: None, + }) + } + + fn doc_scope() -> SigningScope<'static> { + SigningScope::Document { + contract_id: CONTRACT, + document_type_name: "note", + } + } + + fn requirements(scope: SigningScope<'_>) -> KeyRequirements<'_> { + KeyRequirements { + purpose: Purpose::AUTHENTICATION, + security_levels: &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + key_types: None, + scope, + } + } + + #[test] + fn an_unbound_key_covers_every_scope() { + for scope in [ + SigningScope::NonBatch, + doc_scope(), + SigningScope::ContractWide { + contract_id: CONTRACT, + }, + ] { + assert_eq!(bounds_permit(None, scope), Some(true), "{scope:?}"); + } + } + + #[test] + fn a_contract_bound_key_never_signs_outside_a_batch() { + let bounds = ContractBounds::SingleContract { id: CONTRACT }; + assert_eq!( + bounds_permit(Some(&bounds), SigningScope::NonBatch), + Some(false) + ); + } + + #[test] + fn a_single_contract_bound_covers_its_documents_and_tokens_only() { + let bounds = ContractBounds::SingleContract { id: CONTRACT }; + assert_eq!(bounds_permit(Some(&bounds), doc_scope()), Some(true)); + assert_eq!( + bounds_permit( + Some(&bounds), + SigningScope::ContractWide { + contract_id: CONTRACT + } + ), + Some(true) + ); + assert_eq!( + bounds_permit( + Some(&bounds), + SigningScope::Document { + contract_id: OTHER, + document_type_name: "note" + } + ), + Some(false) + ); + } + + #[test] + fn a_document_type_bound_covers_that_type_and_no_token() { + let bounds = ContractBounds::SingleContractDocumentType { + id: CONTRACT, + document_type_name: "note".to_string(), + }; + assert_eq!(bounds_permit(Some(&bounds), doc_scope()), Some(true)); + assert_eq!( + bounds_permit( + Some(&bounds), + SigningScope::Document { + contract_id: CONTRACT, + document_type_name: "other" + } + ), + Some(false) + ); + assert_eq!( + bounds_permit( + Some(&bounds), + SigningScope::ContractWide { + contract_id: CONTRACT + } + ), + Some(false) + ); + } + + #[test] + fn a_contract_group_bound_is_left_to_platform_inside_a_batch() { + let bounds = ContractBounds::ContractGroup { id: OTHER }; + assert_eq!(bounds_permit(Some(&bounds), doc_scope()), None); + assert_eq!( + bounds_permit(Some(&bounds), SigningScope::NonBatch), + Some(false) + ); + } + + /// Regression: requirements accepted every key type unless a caller + /// narrowed them, so a caller that did not picked an EdDSA or BIP13 key, + /// which Platform refuses for every identity-signed transition. + #[test] + fn requirements_take_only_keys_platform_verifies_by_default() { + let typed = |id: u32, key_type: KeyType| { + let mut k = key(id, None); + k.set_key_type(key_type); + k + }; + let eddsa = typed(0, KeyType::EDDSA_25519_HASH160); + let bip13 = typed(1, KeyType::BIP13_SCRIPT_HASH); + let bls = typed(2, KeyType::BLS12_381); + let defaults = + KeyRequirements::new(Purpose::AUTHENTICATION, &[SecurityLevel::HIGH], doc_scope()); + assert_eq!( + select_signing_key([&eddsa, &bip13], defaults, NOW, |_| true), + None + ); + assert_eq!( + select_signing_key([&eddsa, &bip13, &bls], defaults, NOW, |_| true).map(|k| k.id()), + Some(2) + ); + assert_eq!( + select_signing_key( + [&eddsa], + defaults.with_key_types(&[KeyType::EDDSA_25519_HASH160]), + NOW, + |_| true + ) + .map(|k| k.id()), + Some(0), + "an explicit key type list overrides the default" + ); + } + + #[test] + fn expiry_is_reached_at_the_instant_itself() { + let k = key(1, None).with_limits(None, Some(NOW)); + assert!(!k.is_expired_at(NOW - 1)); + assert!(k.is_expired_at(NOW)); + assert!(!is_auto_selectable(&k, doc_scope(), NOW)); + assert!(is_auto_selectable(&k, doc_scope(), NOW - 1)); + } + + #[test] + fn selection_prefers_an_unlimited_key_over_a_limited_one() { + let limited = key(0, None).with_limits(Some(1_000), None); + let unlimited = key(1, None); + let picked = select_signing_key( + [&limited, &unlimited], + requirements(doc_scope()), + NOW, + |_| true, + ); + assert_eq!(picked.map(|k| k.id()), Some(1)); + } + + #[test] + fn selection_falls_back_to_a_limited_key() { + let limited = key(0, None).with_limits(Some(1_000), Some(NOW + 1)); + let picked = select_signing_key([&limited], requirements(doc_scope()), NOW, |_| true); + assert_eq!(picked.map(|k| k.id()), Some(0)); + } + + #[test] + fn selection_skips_expired_disabled_out_of_bounds_and_group_keys() { + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let expired = key(0, None).with_limits(None, Some(NOW)); + let mut disabled = key(1, None); + disabled.set_disabled_at(1); + let elsewhere = key(2, Some(ContractBounds::SingleContract { id: OTHER })); + let grouped = key(3, Some(ContractBounds::ContractGroup { id: OTHER })); + let keys = [&expired, &disabled, &elsewhere, &grouped]; + + assert_eq!( + select_signing_key(keys, requirements(doc_scope()), NOW, |_| true), + None + ); + } + + #[test] + fn selection_takes_a_key_bound_to_the_target_contract() { + let bound = key(0, Some(ContractBounds::SingleContract { id: CONTRACT })); + assert_eq!( + select_signing_key([&bound], requirements(doc_scope()), NOW, |_| true).map(|k| k.id()), + Some(0) + ); + assert_eq!( + select_signing_key([&bound], requirements(SigningScope::NonBatch), NOW, |_| { + true + }), + None, + "but not for a transition outside a batch" + ); + } + + #[test] + fn selection_honours_purpose_security_level_and_key_type() { + let k = key(0, None); + let wrong_level = KeyRequirements { + security_levels: &[SecurityLevel::CRITICAL], + ..requirements(doc_scope()) + }; + let wrong_type = KeyRequirements { + key_types: Some(&[KeyType::BLS12_381]), + ..requirements(doc_scope()) + }; + let wrong_purpose = KeyRequirements { + purpose: Purpose::TRANSFER, + ..requirements(doc_scope()) + }; + for r in [wrong_level, wrong_type, wrong_purpose] { + assert_eq!(select_signing_key([&k], r, NOW, |_| true), None); + } + } + + #[test] + fn caveats_list_blocking_ones_first() { + let k = key(0, Some(ContractBounds::SingleContract { id: OTHER })) + .with_limits(Some(500), Some(NOW - 1)); + let caveats = key_caveats(&k, doc_scope(), NOW); + assert_eq!( + caveats, + vec![ + KeyCaveat::Expired { + expired_at: NOW - 1 + }, + KeyCaveat::OutOfBounds, + KeyCaveat::Budgeted { total_budget: 500 }, + ] + ); + assert!(caveats[0].blocks_signing()); + } + + #[test] + fn a_live_limited_key_has_only_informational_caveats() { + let k = key(0, None).with_limits(Some(500), Some(NOW + 10)); + let caveats = key_caveats(&k, doc_scope(), NOW); + assert_eq!( + caveats, + vec![ + KeyCaveat::Budgeted { total_budget: 500 }, + KeyCaveat::Expiring { + expires_at: NOW + 10 + }, + ] + ); + assert!(caveats.iter().all(|c| !c.blocks_signing())); + } + + #[test] + fn a_plain_key_has_no_caveats() { + assert!(key_caveats(&key(0, None), doc_scope(), NOW).is_empty()); + } + + /// A limited key this device can sign with beats an unlimited key whose + /// private half lives elsewhere (upstream `usable_authentication_key`). + #[test] + fn a_held_limited_key_beats_an_unlimited_key_held_elsewhere() { + let limited = key(4, None).with_limits(Some(100_000), None); + let unlimited = key(1, None); + let picked = select_signing_key( + [&unlimited, &limited], + requirements(doc_scope()), + NOW, + |k| k.id() == 4, + ); + assert_eq!(picked.map(|k| k.id()), Some(4)); + } + + /// An unlimited key still wins when both can sign. + #[test] + fn an_unlimited_key_wins_when_both_can_sign() { + let limited = key(4, None).with_limits(Some(100_000), None); + let unlimited = key(1, None); + let picked = select_signing_key( + [&limited, &unlimited], + requirements(doc_scope()), + NOW, + |_| true, + ); + assert_eq!(picked.map(|k| k.id()), Some(1)); + } + + /// Availability never widens eligibility: a signable but ineligible key + /// is not picked when the eligible one cannot sign. + #[test] + fn availability_does_not_relax_eligibility() { + let bound_elsewhere = key(2, Some(ContractBounds::SingleContract { id: OTHER })); + let eligible = key(1, None); + let picked = select_signing_key( + [&eligible, &bound_elsewhere], + requirements(doc_scope()), + NOW, + |k| k.id() == 2, + ); + assert_eq!(picked, None); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 8b790fd49..425295dc2 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -18,6 +18,7 @@ pub mod grovestark_prover; pub mod identity_balance; pub mod identity_discovery; pub mod identity_key_protection; +pub mod identity_key_usability; pub mod identity_name; pub mod key_input; pub mod legacy_recovery; diff --git a/src/model/qualified_identity/encrypted_key_storage.rs b/src/model/qualified_identity/encrypted_key_storage.rs index 3897bee1a..1e0ff2b05 100644 --- a/src/model/qualified_identity/encrypted_key_storage.rs +++ b/src/model/qualified_identity/encrypted_key_storage.rs @@ -23,90 +23,110 @@ use zeroize::Zeroizing; pub type ResolvedPrivateKey = (QualifiedIdentityPublicKey, Zeroizing<[u8; 32]>); /// Whether a `stored` public half is the same key as the `live` one, ignoring -/// only `disabled_at`. +/// only the fields Platform lets move after the key is added. /// -/// Every field of an `IdentityPublicKey` is immutable once the key is added, with -/// that single exception: disabling a key rewrites it. The stored copy is a -/// snapshot taken when the private half was saved, so plain `==` stops matching -/// as soon as a key is disabled or rotated, and a key this device demonstrably +/// Every identifying field of an `IdentityPublicKey` is immutable once the key +/// is added. The exceptions are state, not identity: disabling a key rewrites +/// `disabled_at`, and an `IdentityKeyLimitsUpdate` (protocol version 14) raises +/// a V1 key's `total_budget` and moves its `expires_at` later. The stored copy +/// is a snapshot taken when the private half was saved, so plain `==` stops +/// matching as soon as any of those move, and a key this device demonstrably /// holds gets reported as missing. /// /// Comparing only the id and the key material would fix that and open a worse /// hole the other way. A main identity's voting key and a linked voter identity's /// key can carry identical `data` under the same `id`, leaving `purpose` as the /// only thing telling them apart — and a lookup that conflates them can hand out, -/// or delete, material the requested key does not own. So this excludes the one -/// field that legitimately moves and nothing else. +/// or delete, material the requested key does not own. So this excludes the +/// fields that legitimately move and nothing else. /// -/// V1 keys (budget/expiry limits) exist only from protocol 14; DET runs protocol -/// 13, so they are compared strictly apart from `disabled_at`. Upstream writes a -/// key without limits as V0, so a V1 key is never the same key as a V0 one. -// TODO(pv14): `IdentityKeyLimitsUpdate` raises `total_budget` and moves -// `expires_at`; decide whether those amounts identify a key once DET supports -// protocol 14. +/// Whether a key has a budget and whether it has an expiry is identity too: a +/// limit can only be raised, never added or removed, so that shape is fixed for +/// the key's life. The key version is not — upstream writes a V1 key without +/// limits as V0 — so a V0 key and a limit-less V1 key with the same fields are +/// the same key. pub(crate) fn same_key(stored: &IdentityPublicKey, live: &IdentityPublicKey) -> bool { + key_identity(stored) == key_identity(live) +} + +/// The fields of `key` that identify it: everything except the state Platform +/// lets move (see [`same_key`]). +/// +/// Destructured exhaustively, and without `..`, on purpose: a field added +/// upstream must break this build rather than be silently ignored. A new field +/// that distinguishes two keys would otherwise leave [`same_key`] reporting a +/// match where there is none — which is how one key's private material ends up +/// attributed to another. Whoever adds it decides here whether it identifies a +/// key or, like `disabled_at`, only describes its state. +fn key_identity(key: &IdentityPublicKey) -> KeyIdentity<'_> { use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dash_sdk::dpp::identity::identity_public_key::v1::IdentityPublicKeyV1; - // Destructured exhaustively, and without `..`, on purpose: a field added - // upstream must break this build rather than be silently ignored. A new field - // that distinguishes two keys would otherwise leave this reporting a match - // where there is none — which is how one key's private material ends up - // attributed to another. Whoever adds it decides here whether it identifies a - // key or, like `disabled_at`, only describes its state. - match (stored, live) { - (IdentityPublicKey::V0(stored), IdentityPublicKey::V0(live)) => { - let IdentityPublicKeyV0 { - id, - purpose, - security_level, - contract_bounds, - key_type, - read_only, - data, - // The one field Platform lets move after a key is added: disabling - // a key rewrites it, and the stored snapshot predates that. - disabled_at: _, - } = stored; - - *id == live.id - && *purpose == live.purpose - && *security_level == live.security_level - && *contract_bounds == live.contract_bounds - && *key_type == live.key_type - && *read_only == live.read_only - && *data == live.data - } - (IdentityPublicKey::V1(stored), IdentityPublicKey::V1(live)) => { - let IdentityPublicKeyV1 { - id, - purpose, - security_level, - contract_bounds, - key_type, - read_only, - data, - // Moves when the key is disabled, as for V0. - disabled_at: _, - total_budget, - expires_at, - } = stored; - - *id == live.id - && *purpose == live.purpose - && *security_level == live.security_level - && *contract_bounds == live.contract_bounds - && *key_type == live.key_type - && *read_only == live.read_only - && *data == live.data - && *total_budget == live.total_budget - && *expires_at == live.expires_at - } - (IdentityPublicKey::V0(_), IdentityPublicKey::V1(_)) - | (IdentityPublicKey::V1(_), IdentityPublicKey::V0(_)) => false, + match key { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level, + contract_bounds, + key_type, + read_only, + data, + // Moves when the key is disabled; the stored snapshot predates that. + disabled_at: _, + }) => KeyIdentity { + id: *id, + purpose: *purpose, + security_level: *security_level, + contract_bounds: contract_bounds.as_ref(), + key_type: *key_type, + read_only: *read_only, + data: data.as_slice(), + has_budget: false, + has_expiry: false, + }, + IdentityPublicKey::V1(IdentityPublicKeyV1 { + id, + purpose, + security_level, + contract_bounds, + key_type, + read_only, + data, + // Moves when the key is disabled. + disabled_at: _, + // The amounts move with `IdentityKeyLimitsUpdate`; only whether the + // key has each limit is fixed. + total_budget, + expires_at, + }) => KeyIdentity { + id: *id, + purpose: *purpose, + security_level: *security_level, + contract_bounds: contract_bounds.as_ref(), + key_type: *key_type, + read_only: *read_only, + data: data.as_slice(), + has_budget: total_budget.is_some(), + has_expiry: expires_at.is_some(), + }, } } +/// The identifying fields of an identity public key, whatever its version. +#[derive(PartialEq, Eq)] +struct KeyIdentity<'a> { + id: KeyID, + purpose: Purpose, + security_level: SecurityLevel, + contract_bounds: + Option<&'a dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds>, + key_type: dash_sdk::dpp::identity::KeyType, + read_only: bool, + data: &'a [u8], + has_budget: bool, + has_expiry: bool, +} + /// A `(target, key_id)` map key paired with the raw 32-byte private key the /// migration must store in the vault — see /// [`KeyStorage::take_plaintext_for_vault`]. Bytes are [`Zeroizing`]. @@ -1426,9 +1446,22 @@ mod tests { )); } - /// A V1 key disabled since its snapshot was taken is still the same key. + /// A V1 key's budget and expiry are raised by `IdentityKeyLimitsUpdate`, so + /// a snapshot taken before the raise still names the key. + #[test] + fn same_key_ignores_raised_limits() { + let base = IdentityPublicKey::random_key(0, Some(1), PlatformVersion::latest()); + let stored = base.clone().with_limits(Some(1_000), Some(1_800_000)); + let raised = base.with_limits(Some(5_000), Some(9_000_000)); + + assert!(same_key(&stored, &raised), "raised limits keep the key"); + assert!(same_key(&raised, &stored), "in either direction"); + } + + /// A limited key is disabled the same way any other key is, and the state + /// it carries is no more identifying for having limits beside it. #[test] - fn same_key_ignores_a_v1_key_being_disabled() { + fn same_key_ignores_a_limited_key_being_disabled() { use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; let stored = IdentityPublicKey::random_key(0, Some(1), PlatformVersion::latest()) @@ -1440,31 +1473,41 @@ mod tests { assert!(same_key(&disabled_since, &stored)); } - /// A V1 key carries limits a V0 key cannot, so the two are never the same - /// key, and V1 limits are compared strictly — `total_budget` and - /// `expires_at` each on their own, so dropping either conjunct fails here. + /// Whether a key has a budget or an expiry is fixed when it is added: a + /// limit can be raised but never added or removed. A disagreement on the + /// presence of a limit is a different key. #[test] - fn same_key_rejects_a_v1_key_against_v0_or_other_limits() { + fn same_key_rejects_a_different_limit_shape() { let base = IdentityPublicKey::random_key(0, Some(1), PlatformVersion::latest()); - let limited = base.clone().with_limits(Some(1_000), None); - let raised = base.clone().with_limits(Some(5_000), None); - // Same budget as `limited`, differing only in when the key expires. - let expiring = base.clone().with_limits(Some(1_000), Some(1_800_000)); - let expiring_later = base.clone().with_limits(Some(1_000), Some(2_400_000)); - - assert!(!same_key(&base, &limited)); - assert!(!same_key(&limited, &base)); - assert!(!same_key(&limited, &raised)); + let budget_only = base.clone().with_limits(Some(1_000), None); + let expiry_only = base.clone().with_limits(None, Some(1_800_000)); + let limits_none = base.clone().with_limits(None, None); + assert!(!same_key(&budget_only, &expiry_only)); + assert!( + !same_key(&budget_only, &limits_none), + "a budget alone tells two keys apart" + ); assert!( - !same_key(&limited, &expiring), - "an expiry added since the snapshot is a different key" + !same_key(&expiry_only, &limits_none), + "and so does an expiry alone" ); - assert!(!same_key(&expiring, &limited)); assert!( - !same_key(&expiring, &expiring_later), - "a moved expiry is a different key, at an identical budget" + !same_key(&base, &budget_only), + "a V0 key never gains a budget" ); + assert!(!same_key(&expiry_only, &base), "nor an expiry"); + } + + /// Upstream writes a V1 key without limits as V0, so the two encodings of + /// one key must still compare equal. + #[test] + fn same_key_treats_a_limitless_v1_key_as_its_v0_form() { + let base = IdentityPublicKey::random_key(0, Some(1), PlatformVersion::latest()); + let limits_none = base.clone().with_limits(None, None); + + assert!(same_key(&base, &limits_none)); + assert!(same_key(&limits_none, &base)); } /// A storage filing one key under several placements, each with the given diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index e91d94de3..b8de20040 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -8,6 +8,13 @@ pub mod qualified_identity_public_key; // requires making that secret-seam chokepoint generic over the closure error // type — a wallet_backend change out of scope here. use crate::backend_task::error::{SigningKeyUnavailableCause, TaskError}; +use crate::model::dpns_usernames::{ + DPNS_SIGNING_KEY_TYPES, dpns_security_levels, dpns_signing_requirement, +}; +use crate::model::identity_key_usability::{ + KeyRequirements, PLATFORM_SIGNING_KEY_TYPES, SigningScope, bounds_permit, + document_security_levels, now_ms, select_identity_signing_key, +}; use crate::model::qualified_identity::encrypted_key_storage::{ KeyStorage, ResolvedPrivateKey, same_key, }; @@ -21,8 +28,9 @@ use dash_sdk::dpp::async_trait::async_trait; use dash_sdk::dpp::dashcore::address::Payload; use dash_sdk::dpp::dashcore::hashes::Hash; use dash_sdk::dpp::dashcore::{Address, Network, ScriptHash}; +use dash_sdk::dpp::data_contract::DataContract; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; -use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dash_sdk::dpp::ed25519_dalek::Signer as EDDSASigner; use dash_sdk::dpp::identity::KeyType::{BIP13_SCRIPT_HASH, ECDSA_HASH160}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -36,7 +44,7 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::state_transition::errors::InvalidIdentityPublicKeyTypeError; use dash_sdk::dpp::{ProtocolError, bls, ed25519_dalek}; use dash_sdk::platform::IdentityPublicKey; -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use std::fmt::{Display, Formatter}; use std::sync::{Arc, RwLock}; use zeroize::Zeroizing; @@ -570,6 +578,42 @@ impl Signer for QualifiedIdentity { /// compatible with blobs `to_bytes` already wrote. const IDENTITY_BLOB_DECODE_LIMIT: usize = 16 * 1024 * 1024; // 16 MiB +/// The key types a DashPay document (contactRequest, contactInfo, profile) is +/// signed with: ECDSA keys, as upstream platform-wallet's DashPay writes +/// require. `ECDSA_HASH160` is taken too, as dashpay/platform#5546 adds it +/// upstream; DET accepted it before, so dropping it would strand identities +/// whose only document key is a hash160 key. +// TODO(upstream): take the key types from platform-wallet's public DashPay +// key selector once it exists, instead of restating them here. +pub const DASHPAY_SIGNING_KEY_TYPES: &[KeyType] = + &[KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160]; + +/// The key purposes DPP accepts for a batch of one token transfer or claim, +/// in the order [`QualifiedIdentity::single_token_transition_key`] tries them +/// (`BatchTransitionV1::purpose_requirement`; TRANSFER requires CRITICAL). +const SINGLE_TOKEN_TRANSITION_PURPOSES: [Purpose; 2] = [Purpose::AUTHENTICATION, Purpose::TRANSFER]; + +/// Whether an identity can sign a token transition on a contract on this +/// device, as [`QualifiedIdentity::single_token_transition_key_status`] and +/// [`QualifiedIdentity::token_action_key_status`] report it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigningKeyStatus { + /// The transition's key choice picks a key. + Usable, + /// The key choice picks none, but a live key this device holds is bound + /// to a contract group, which only Platform can match to the contract, + /// so the user may choose it. + ManualChoiceOnly, + /// A private key of a purpose, level and key type the transition takes + /// is filed, but none can sign it now: each is disabled or expired, bound + /// to another contract, or its private half is not readable on this + /// device. + HeldButUnusable, + /// No private key of a purpose, level and key type the transition takes + /// is filed. + NoneHeld, +} + /// The bincode configuration [`QualifiedIdentity::from_bytes`] decodes under. /// Pulled into its own function (rather than inlined at the one call site) so /// the decode-limit regression test below exercises the *exact* configuration @@ -1133,6 +1177,21 @@ impl QualifiedIdentity { }) } + /// The MASTER key to sign an identity update (adding or disabling keys) + /// with, picked as [`Self::signing_key_now`] picks, so a disabled or + /// expired master, or one whose private half is not here, is passed over. + /// Only user identities hold a master key. + pub fn identity_update_signing_key(&self) -> Option<&IdentityPublicKey> { + if self.identity_type != IdentityType::User { + return None; + } + self.signing_key_now(KeyRequirements::new( + Purpose::AUTHENTICATION, + &[SecurityLevel::MASTER], + SigningScope::NonBatch, + )) + } + pub fn can_sign_with_master_key(&self) -> Option<&QualifiedIdentityPublicKey> { if self.identity_type != IdentityType::User { return None; @@ -1151,18 +1210,262 @@ impl QualifiedIdentity { None } + /// The key to sign document transitions on `document_type` with, within + /// `scope`: a live AUTHENTICATION key of one of `key_types`, at a level the + /// type accepts ([`document_security_levels`]), whose contract bounds + /// permit `scope`, in upstream platform-wallet's order (see + /// [`Self::signing_key_now`]). pub fn document_signing_key( &self, + scope: SigningScope<'_>, document_type: &DocumentTypeRef, + key_types: &[KeyType], + ) -> Option<&IdentityPublicKey> { + let levels = document_security_levels(*document_type); + self.signing_key_now( + KeyRequirements::new(Purpose::AUTHENTICATION, &levels, scope).with_key_types(key_types), + ) + } + + /// The key to register a username with: a live AUTHENTICATION key of one + /// of [`DPNS_SIGNING_KEY_TYPES`], at a level both registration documents + /// (preorder and domain) accept ([`dpns_security_levels`] of + /// [`dpns_signing_requirement`], the levels the Advanced chooser offers + /// and the backend checks a chosen key against), whose bounds permit the + /// DPNS contract as a whole, picked as [`Self::signing_key_now`] picks. + /// `None` when no key qualifies. + pub fn username_registration_key( + &self, + dpns_contract: &DataContract, + ) -> Option<&IdentityPublicKey> { + let levels = dpns_security_levels(dpns_signing_requirement(dpns_contract)); + self.signing_key_now( + KeyRequirements::new( + Purpose::AUTHENTICATION, + &levels, + SigningScope::ContractWide { + contract_id: dpns_contract.id(), + }, + ) + .with_key_types(DPNS_SIGNING_KEY_TYPES), + ) + } + + /// The key to sign an action on document type `document_type_name` of + /// `contract` with: a live AUTHENTICATION key at a level the type accepts + /// ([`document_security_levels`]), of one of `key_types`, whose bounds + /// permit that document type, in upstream platform-wallet's order (see + /// [`Self::signing_key_now`]). `None` when the contract has no such type + /// or no key qualifies. + pub fn document_action_signing_key( + &self, + contract: &DataContract, + document_type_name: &str, + key_types: &[KeyType], ) -> Option<&IdentityPublicKey> { - self.identity.get_first_public_key_matching( + let document_type = contract.document_type_for_name(document_type_name).ok()?; + let levels = document_security_levels(document_type); + self.signing_key_now( + KeyRequirements::new( + Purpose::AUTHENTICATION, + &levels, + SigningScope::Document { + contract_id: contract.id(), + document_type_name, + }, + ) + .with_key_types(key_types), + ) + } + + /// The key to sign a `document_type_name` DashPay document with: a + /// [`Self::document_action_signing_key`] of one of + /// [`DASHPAY_SIGNING_KEY_TYPES`]. + pub fn dashpay_signing_key( + &self, + dashpay_contract: &DataContract, + document_type_name: &str, + ) -> Option<&IdentityPublicKey> { + self.document_action_signing_key( + dashpay_contract, + document_type_name, + DASHPAY_SIGNING_KEY_TYPES, + ) + } + + /// The key as the identity holds it now for `snapshot`, a copy taken + /// earlier (e.g. stored next to its private half): budget and expiry move + /// with key limits updates, so the snapshot may be stale. Falls back to + /// the snapshot when the identity no longer holds the same key. + pub fn live_public_key<'a>(&'a self, snapshot: &'a IdentityPublicKey) -> &'a IdentityPublicKey { + self.current_public_key(snapshot).unwrap_or(snapshot) + } + + /// The key as the identity holds it now for `snapshot`, or `None` when + /// the identity no longer holds the same key, so nothing it signs can + /// authorize a transition. + pub fn current_public_key(&self, snapshot: &IdentityPublicKey) -> Option<&IdentityPublicKey> { + self.identity + .public_keys() + .get(&snapshot.id()) + .filter(|live| encrypted_key_storage::same_key(snapshot, live)) + } + + /// The key to sign with for `requirements` now: the automatic choice of + /// [`select_identity_signing_key`] among the keys whose private half this + /// device can obtain ([`Self::holds_obtainable_key`]), so a key filed + /// under a missing wallet or in the legacy encrypted form is passed over + /// for the next usable one. Upstream platform-wallet's order: every + /// qualifying unlimited key at any accepted level before any limited one, + /// then key id order, so a budgeted application key is spent only when no + /// unlimited key can sign. + pub fn signing_key_now(&self, requirements: KeyRequirements<'_>) -> Option<&IdentityPublicKey> { + select_identity_signing_key(&self.identity, requirements, now_ms(), |key| { + self.holds_obtainable_key(key) + }) + } + + /// The key to sign a batch of one token transfer or claim on + /// `contract_id` with: a CRITICAL AUTHENTICATION key, else a CRITICAL + /// TRANSFER key, which DPP also accepts for such a batch (and requires + /// CRITICAL for), each picked as [`Self::signing_key_now`] picks. + pub fn single_token_transition_key( + &self, + contract_id: dash_sdk::platform::Identifier, + ) -> Option<&IdentityPublicKey> { + let scope = SigningScope::ContractWide { contract_id }; + SINGLE_TOKEN_TRANSITION_PURPOSES + .into_iter() + .find_map(|purpose| { + self.signing_key_now( + KeyRequirements::new(purpose, &[SecurityLevel::CRITICAL], scope) + .with_key_types(PLATFORM_SIGNING_KEY_TYPES), + ) + }) + } + + /// Whether this device can sign a batch of one token transfer or claim on + /// `contract_id`: the gate in front of the token transfer form, and of + /// the claim form for a user identity. It opens exactly when + /// [`Self::single_token_transition_key`] picks a key, so an open form can + /// always sign; when closed, it tells a missing key apart from keys that + /// cannot sign here, which need different guidance. + /// [`SigningKeyStatus::ManualChoiceOnly`] opens it onto the key chooser. + pub fn single_token_transition_key_status( + &self, + contract_id: dash_sdk::platform::Identifier, + ) -> SigningKeyStatus { + let scope = SigningScope::ContractWide { contract_id }; + if self.single_token_transition_key(contract_id).is_some() { + SigningKeyStatus::Usable + } else if SINGLE_TOKEN_TRANSITION_PURPOSES.into_iter().any(|purpose| { + self.holds_contract_group_key_for(KeyRequirements::new( + purpose, + &[SecurityLevel::CRITICAL], + scope, + )) + }) { + SigningKeyStatus::ManualChoiceOnly + } else if self.holds_single_token_transition_key() { + SigningKeyStatus::HeldButUnusable + } else { + SigningKeyStatus::NoneHeld + } + } + + /// The requirements of a token action on `contract_id` other than a + /// single transfer or claim (mint, burn, freeze, pause, pricing, purchase, + /// configuration updates): a CRITICAL AUTHENTICATION key valid for the + /// token's contract, of a type Platform verifies. + fn token_action_requirements( + contract_id: dash_sdk::platform::Identifier, + ) -> KeyRequirements<'static> { + KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([document_type.security_level_requirement()]), - HashSet::from(KeyType::all_key_types()), - false, + &[SecurityLevel::CRITICAL], + SigningScope::ContractWide { contract_id }, ) } + /// The key to sign a token action on `contract_id` with, as + /// [`Self::signing_key_now`] picks it. + pub fn token_action_key( + &self, + contract_id: dash_sdk::platform::Identifier, + ) -> Option<&IdentityPublicKey> { + self.signing_key_now(Self::token_action_requirements(contract_id)) + } + + /// Whether this device can sign a token action on `contract_id`: the gate + /// in front of the token action forms. It opens exactly when + /// [`Self::token_action_key`] picks a key, so an open form can always + /// sign; when closed, it tells a missing key apart from keys that cannot + /// sign here. [`SigningKeyStatus::ManualChoiceOnly`] opens it onto the key + /// chooser. + pub fn token_action_key_status( + &self, + contract_id: dash_sdk::platform::Identifier, + ) -> SigningKeyStatus { + let requirements = Self::token_action_requirements(contract_id); + if self.signing_key_now(requirements).is_some() { + SigningKeyStatus::Usable + } else if self.holds_contract_group_key_for(requirements) { + SigningKeyStatus::ManualChoiceOnly + } else if self + .private_keys + .identity_public_keys() + .iter() + .any(|key_ref| requirements.matches(&key_ref.1.identity_public_key)) + { + SigningKeyStatus::HeldButUnusable + } else { + SigningKeyStatus::NoneHeld + } + } + + /// Whether a live key matching `requirements`, whose private half this + /// device can obtain, is bound to a contract group: never picked + /// automatically, as only Platform can tell whether the group covers the + /// contract, but the user may choose it. + fn holds_contract_group_key_for(&self, requirements: KeyRequirements<'_>) -> bool { + use dash_sdk::dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; + let now = now_ms(); + self.identity.public_keys().values().any(|key| { + requirements.matches(key) + && !key.is_disabled() + && !key.is_expired_at(now) + && bounds_permit(key.contract_bounds(), requirements.scope).is_none() + && self.holds_obtainable_key(key) + }) + } + + /// Whether a private key of a purpose, level and key type + /// [`Self::single_token_transition_key`] takes is filed for the identity, + /// whether or not it can sign now. + fn holds_single_token_transition_key(&self) -> bool { + self.private_keys + .identity_public_keys() + .iter() + .any(|key_ref| { + let key = &key_ref.1.identity_public_key; + SINGLE_TOKEN_TRANSITION_PURPOSES.contains(&key.purpose()) + && key.security_level() == SecurityLevel::CRITICAL + && PLATFORM_SIGNING_KEY_TYPES.contains(&key.key_type()) + }) + } + + /// The key [`select_identity_signing_key`] would pick for `requirements` + /// now, whether or not this device holds its private half. For + /// informational lookups only (e.g. pointing the user at a key whose + /// private half they should load) — never for signing; use + /// [`Self::signing_key_now`] for that. + pub fn matching_key_now( + &self, + requirements: KeyRequirements<'_>, + ) -> Option<&IdentityPublicKey> { + select_identity_signing_key(&self.identity, requirements, now_ms(), |_| true) + } + pub fn available_withdrawal_keys(&self) -> Vec<&QualifiedIdentityPublicKey> { let mut keys = vec![]; @@ -1198,95 +1501,115 @@ impl QualifiedIdentity { keys } - /// Whether the active role can attempt a withdrawal with this identity. + /// Whether the active role can attempt a withdrawal with this identity: + /// exactly when [`Self::withdrawal_signing_key`] picks a key, the key the + /// withdrawal form pre-selects and the backend signs with when none is + /// chosen, so an open form can always sign. /// - /// Uniform across every role: a withdrawal is only ever executable with a - /// locally-held TRANSFER or OWNER key (see - /// [`resolve_withdrawal_signing_key`](Self::resolve_withdrawal_signing_key), - /// the backend's unconditional enforcement layer — no role currently - /// relaxes it). A Developer-only "any on-chain key" carve-out previously - /// existed here and in [`default_withdrawal_key`](Self::default_withdrawal_key)'s - /// caller, but nothing downstream ever accepted an on-chain-only key for - /// signing, so that carve-out only produced an enabled button that led to - /// a blank or failing withdrawal screen. Gate on the same invariant the - /// backend enforces, so this predicate never lies about what the screen - /// can actually do. + /// Uniform across every role. A Developer-only "any on-chain key" + /// carve-out previously existed here, but nothing downstream ever accepted + /// an on-chain-only key for signing, so it only produced an enabled button + /// that led to a blank or failing withdrawal screen. pub fn can_attempt_withdrawal(&self, _role: UserRole) -> bool { - !self.available_withdrawal_keys().is_empty() + self.withdrawal_key_status() == SigningKeyStatus::Usable } - /// Returns the key to pre-select for signing a withdrawal. - /// - /// Only keys whose private material is held locally are considered (via - /// [`available_withdrawal_keys`](Self::available_withdrawal_keys)). A - /// `TRANSFER` key is preferred, falling back to an `OWNER` key — mirroring - /// Platform's `TransferPreferred` signing-key selection. Returns `None` when - /// no locally-signable withdrawal key exists, so callers never pre-select an - /// on-chain key the signer cannot actually use. - pub fn default_withdrawal_key(&self) -> Option<&QualifiedIdentityPublicKey> { - let keys = self.available_withdrawal_keys(); - keys.iter() - .find(|qk| qk.identity_public_key.purpose() == Purpose::TRANSFER) - .or_else(|| { - keys.iter() - .find(|qk| qk.identity_public_key.purpose() == Purpose::OWNER) + /// The purposes a withdrawal from this identity may be signed with, in + /// preference order: `TRANSFER`, then `OWNER` for a masternode or evonode, + /// mirroring Platform's `TransferPreferred` selection. + fn withdrawal_purposes(&self) -> &'static [Purpose] { + match self.identity_type { + IdentityType::User => &[Purpose::TRANSFER], + IdentityType::Masternode | IdentityType::Evonode => { + &[Purpose::TRANSFER, Purpose::OWNER] + } + } + } + + /// The key to sign a withdrawal with when none is chosen: a live key of + /// one of [`Self::withdrawal_purposes`] (the first purpose with a usable + /// key wins), of a type Platform verifies, not bound to a contract (a + /// withdrawal is not a batch), picked as [`Self::signing_key_now`] picks, + /// so a key Platform refuses is passed over for the next usable one. + pub fn withdrawal_signing_key(&self) -> Option<&IdentityPublicKey> { + let levels = SecurityLevel::full_range(); + self.withdrawal_purposes().iter().find_map(|purpose| { + self.signing_key_now(KeyRequirements::new( + *purpose, + &levels, + SigningScope::NonBatch, + )) + }) + } + + /// Whether this device can sign a withdrawal from this identity: usable + /// exactly when [`Self::withdrawal_signing_key`] picks a key; when not, it + /// tells a filed key that cannot sign apart from no filed key at all. + pub fn withdrawal_key_status(&self) -> SigningKeyStatus { + if self.withdrawal_signing_key().is_some() { + SigningKeyStatus::Usable + } else if self.holds_main_identity_key_of(self.withdrawal_purposes()) { + SigningKeyStatus::HeldButUnusable + } else { + SigningKeyStatus::NoneHeld + } + } + + /// Whether a private key of one of `purposes` is filed for the main + /// identity, whether or not it can sign now. + fn holds_main_identity_key_of(&self, purposes: &[Purpose]) -> bool { + self.private_keys + .identity_public_keys() + .iter() + .any(|(target, key)| { + **target == PrivateKeyTarget::PrivateKeyOnMainIdentity + && purposes.contains(&key.identity_public_key.purpose()) }) - .copied() } /// Resolves the effective key to sign an identity credit withdrawal, /// returning its [`KeyID`]. /// - /// `requested` is the caller's explicit key choice, or `None` to auto-select. - /// Resolution runs against `self.identity`, which the caller must have - /// refreshed from Platform first: a key disabled on-chain since the identity - /// was last loaded is rejected here rather than reaching signing. Only keys - /// whose private material this app holds and whose purpose is `TRANSFER` - /// (preferred) or `OWNER` are eligible, mirroring Platform's - /// `TransferPreferred` selection. + /// `requested` is the caller's explicit key choice, or `None` to take + /// [`Self::withdrawal_signing_key`]'s choice. Resolution runs against + /// `self.identity`, which the caller must have refreshed from Platform + /// first: a key disabled on-chain since the identity was last loaded is + /// rejected here rather than reaching signing. /// - /// An explicit `requested` id that is unknown, disabled, or not locally - /// signable is rejected — never silently replaced with a different key. - /// Returns [`NoUsableWithdrawalKey`] when nothing eligible remains, so the - /// caller can surface a clear error instead of letting the SDK pick a key - /// (which would allow a disabled key or an unintended `OWNER` fallback). + /// An explicit `requested` id must be a locally held `TRANSFER` or `OWNER` + /// key ([`Self::available_withdrawal_keys`]) that is active and whose + /// private half this device can obtain; otherwise it is rejected — never + /// silently replaced with a different key. Its expiry is not checked: the + /// key chooser offers an expired key with a warning, and Platform decides + /// by network time. Returns [`NoUsableWithdrawalKey`] when nothing + /// eligible remains, so the caller can surface a clear error instead of + /// letting the SDK pick a key (which would allow a disabled key or an + /// unintended `OWNER` fallback). pub fn resolve_withdrawal_signing_key( &self, requested: Option, ) -> Result { - // Locally-held TRANSFER/OWNER keys that are also active in the current - // (refreshed) identity snapshot. `available_withdrawal_keys` filters on - // the stored copy's disabled flag; the extra checks catch a key that was - // disabled on-chain after this identity was loaded, and one whose - // private half this device cannot obtain. - let usable: Vec<&QualifiedIdentityPublicKey> = self - .available_withdrawal_keys() - .into_iter() - .filter(|qk| { - matches!( - self.identity.get_public_key_by_id(qk.identity_public_key.id()), - Some(current) if !current.is_disabled() - ) && self.holds_obtainable_key(&qk.identity_public_key) - }) - .collect(); - - if let Some(requested_id) = requested { - return usable - .iter() - .any(|qk| qk.identity_public_key.id() == requested_id) - .then_some(requested_id) + let Some(requested_id) = requested else { + return self + .withdrawal_signing_key() + .map(|key| key.id()) .ok_or(NoUsableWithdrawalKey); - } - - usable - .iter() - .find(|qk| qk.identity_public_key.purpose() == Purpose::TRANSFER) - .or_else(|| { - usable - .iter() - .find(|qk| qk.identity_public_key.purpose() == Purpose::OWNER) + }; + // `available_withdrawal_keys` filters on the stored copy's disabled + // flag; the extra checks catch a key that was disabled on-chain after + // this identity was loaded, and one whose private half this device + // cannot obtain. + self.available_withdrawal_keys() + .into_iter() + .any(|qk| { + qk.identity_public_key.id() == requested_id + && matches!( + self.identity.get_public_key_by_id(requested_id), + Some(current) if !current.is_disabled() + ) + && self.holds_obtainable_key(&qk.identity_public_key) }) - .map(|qk| qk.identity_public_key.id()) + .then_some(requested_id) .ok_or(NoUsableWithdrawalKey) } @@ -1337,34 +1660,61 @@ impl QualifiedIdentity { } } + /// The key to sign a credit transfer with when none is chosen: a live + /// `TRANSFER` key of a type Platform verifies, not bound to a contract (a + /// transfer is not a batch), picked as [`Self::signing_key_now`] picks, so + /// a key Platform refuses is passed over for the next usable one. + pub fn transfer_signing_key(&self) -> Option<&IdentityPublicKey> { + self.signing_key_now(KeyRequirements::new( + Purpose::TRANSFER, + &SecurityLevel::full_range(), + SigningScope::NonBatch, + )) + } + + /// Whether this device can sign a credit transfer from this identity: + /// usable exactly when [`Self::transfer_signing_key`] picks a key; when + /// not, it tells a filed `TRANSFER` key that cannot sign apart from no + /// filed `TRANSFER` key at all. + pub fn transfer_key_status(&self) -> SigningKeyStatus { + if self.transfer_signing_key().is_some() { + SigningKeyStatus::Usable + } else if self.holds_main_identity_key_of(&[Purpose::TRANSFER]) { + SigningKeyStatus::HeldButUnusable + } else { + SigningKeyStatus::NoneHeld + } + } + /// Resolves the key to sign an identity credit transfer, returning its /// [`KeyID`]. /// - /// `requested` is the caller's explicit key choice, or `None` to take the - /// usable key with the lowest id. A key is usable when `self.identity` - /// lists it as an active `TRANSFER` key and this device can obtain its - /// private half ([`holds_obtainable_key`](Self::holds_obtainable_key)). + /// `requested` is the caller's explicit key choice, or `None` to take + /// [`Self::transfer_signing_key`]'s choice. /// - /// An explicit `requested` id that is unknown, disabled, or not locally - /// signable is rejected — never silently replaced with a different key. - /// Returns [`NoUsableTransferKey`] when nothing usable remains, so the - /// caller can say so instead of letting the SDK take the identity's first - /// `TRANSFER` key, which may be disabled or not held here. + /// An explicit `requested` id must be a `TRANSFER` key `self.identity` + /// lists as active, whose private half this device can obtain + /// ([`holds_obtainable_key`](Self::holds_obtainable_key)); otherwise it is + /// rejected — never silently replaced with a different key. Its expiry is + /// not checked: the key chooser offers an expired key with a warning, and + /// Platform decides by network time. Returns [`NoUsableTransferKey`] when + /// nothing usable remains, so the caller can say so instead of letting + /// the SDK take the identity's first `TRANSFER` key, which may be disabled + /// or not held here. pub fn resolve_transfer_signing_key( &self, requested: Option, ) -> Result { - let usable = |key: &&IdentityPublicKey| { - key.purpose() == Purpose::TRANSFER - && !key.is_disabled() - && self.holds_obtainable_key(key) - }; match requested { Some(requested_id) => self .identity .get_public_key_by_id(requested_id) - .filter(usable), - None => self.identity.public_keys().values().find(usable), + .filter(|key| { + key.purpose() == Purpose::TRANSFER + && !key.is_disabled() + && self.holds_obtainable_key(key) + }), + None => self.transfer_signing_key(), } .map(|key| key.id()) .ok_or(NoUsableTransferKey) @@ -1398,7 +1748,7 @@ impl QualifiedIdentity { /// Whether this device holds `key` under a placement whose private half it /// can obtain. Stricter than [`Signer::can_sign_with`], which also accepts a /// key that only refers to a wallet this device does not hold. - fn holds_obtainable_key(&self, key: &IdentityPublicKey) -> bool { + pub(crate) fn holds_obtainable_key(&self, key: &IdentityPublicKey) -> bool { self.private_keys.candidates(key).any(|placement| { self.private_keys .entry_at(&placement) @@ -2566,11 +2916,16 @@ pub(crate) mod withdrawal_key_tests { use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::Identifier; + /// A CRITICAL `purpose` key of a type Platform verifies (ECDSA secp256k1), + /// derived from `id` so two calls with the same id build the same key. pub(crate) fn key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { - let mut k = IdentityPublicKey::random_key(id, Some(id as u64), PlatformVersion::latest()); - k.set_id(id); + let (mut k, _) = IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + id, + Some(id as u64), + PlatformVersion::latest(), + ) + .expect("a random ECDSA key"); k.set_purpose(purpose); - k.set_security_level(SecurityLevel::CRITICAL); k } @@ -2624,25 +2979,49 @@ pub(crate) mod withdrawal_key_tests { fn ghost_transfer_key_is_not_selected() { let transfer = key(1, Purpose::TRANSFER); let qi = build_identity(IdentityType::User, vec![transfer], vec![]); - assert!(qi.default_withdrawal_key().is_none()); + assert!(qi.withdrawal_signing_key().is_none()); + } + + /// Regression: the withdraw screen's "Check Owner Key" / "Check Payout + /// Address Key" buttons render exactly when no signable key is held, so + /// their lookup must still find a public-only key. + #[test] + fn a_public_only_owner_key_is_found_for_information_but_not_for_signing() { + let owner = key(2, Purpose::OWNER); + let transfer = key(3, Purpose::TRANSFER); + let qi = build_identity(IdentityType::Masternode, vec![owner, transfer], vec![]); + let levels = SecurityLevel::full_range(); + let requirements = |purpose| KeyRequirements::new(purpose, &levels, SigningScope::NonBatch); + + assert!(qi.signing_key_now(requirements(Purpose::OWNER)).is_none()); + assert_eq!( + qi.matching_key_now(requirements(Purpose::OWNER)) + .map(|k| k.id()), + Some(2) + ); + assert_eq!( + qi.matching_key_now(requirements(Purpose::TRANSFER)) + .map(|k| k.id()), + Some(3) + ); } #[test] fn private_backed_transfer_key_is_selected() { let transfer = key(1, Purpose::TRANSFER); let qi = build_identity(IdentityType::User, vec![transfer.clone()], vec![transfer]); - let selected = qi.default_withdrawal_key().expect("a key"); - assert_eq!(selected.identity_public_key.id(), 1); - assert_eq!(selected.identity_public_key.purpose(), Purpose::TRANSFER); + let selected = qi.withdrawal_signing_key().expect("a key"); + assert_eq!(selected.id(), 1); + assert_eq!(selected.purpose(), Purpose::TRANSFER); } #[test] fn owner_key_is_used_as_fallback_when_no_transfer() { let owner = key(2, Purpose::OWNER); let qi = build_identity(IdentityType::Masternode, vec![owner.clone()], vec![owner]); - let selected = qi.default_withdrawal_key().expect("a key"); - assert_eq!(selected.identity_public_key.id(), 2); - assert_eq!(selected.identity_public_key.purpose(), Purpose::OWNER); + let selected = qi.withdrawal_signing_key().expect("a key"); + assert_eq!(selected.id(), 2); + assert_eq!(selected.purpose(), Purpose::OWNER); } #[test] @@ -2654,8 +3033,8 @@ pub(crate) mod withdrawal_key_tests { vec![owner.clone(), transfer.clone()], vec![owner, transfer], ); - let selected = qi.default_withdrawal_key().expect("a key"); - assert_eq!(selected.identity_public_key.purpose(), Purpose::TRANSFER); + let selected = qi.withdrawal_signing_key().expect("a key"); + assert_eq!(selected.purpose(), Purpose::TRANSFER); } pub(crate) fn disabled_key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { @@ -2677,9 +3056,9 @@ pub(crate) mod withdrawal_key_tests { vec![disabled.clone(), active.clone()], vec![disabled, active], ); - let selected = qi.default_withdrawal_key().expect("a key"); - assert_eq!(selected.identity_public_key.id(), 1); - assert!(!selected.identity_public_key.is_disabled()); + let selected = qi.withdrawal_signing_key().expect("a key"); + assert_eq!(selected.id(), 1); + assert!(!selected.is_disabled()); } #[test] @@ -2738,7 +3117,7 @@ pub(crate) mod withdrawal_key_tests { vec![disabled.clone()], vec![disabled], ); - assert!(qi.default_withdrawal_key().is_none()); + assert!(qi.withdrawal_signing_key().is_none()); assert!(qi.available_withdrawal_keys().is_empty()); } @@ -2759,9 +3138,9 @@ pub(crate) mod withdrawal_key_tests { ], vec![disabled_transfer, owner, active_transfer], ); - let selected = qi.default_withdrawal_key().expect("a key"); - assert_eq!(selected.identity_public_key.id(), 2); - assert_eq!(selected.identity_public_key.purpose(), Purpose::TRANSFER); + let selected = qi.withdrawal_signing_key().expect("a key"); + assert_eq!(selected.id(), 2); + assert_eq!(selected.purpose(), Purpose::TRANSFER); } /// Auto-select (no explicit id) resolves the active TRANSFER key, matching @@ -3081,6 +3460,110 @@ pub(crate) mod withdrawal_key_tests { ); } + /// Keys Platform refuses for a credit transfer or withdrawal, each with id + /// 1: expired, bound to a contract (refused outside a batch), and of a type + /// Platform does not verify. + fn refused_credit_keys(purpose: Purpose) -> Vec<(&'static str, IdentityPublicKey)> { + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let mut bound = key(1, purpose); + if let IdentityPublicKey::V0(v0) = &mut bound { + v0.contract_bounds = Some(ContractBounds::SingleContract { + id: Identifier::new([0x09; 32]), + }); + } + let mut eddsa = key(1, purpose); + eddsa.set_key_type(KeyType::EDDSA_25519_HASH160); + vec![ + ("expired", key(1, purpose).with_limits(None, Some(1))), + ("contract bound", bound), + ("EdDSA", eddsa), + ] + } + + /// Regression: the automatic transfer and withdrawal choices checked only + /// purpose, disabled state and the private half, so a key Platform + /// refuses won over a later usable key and the send failed. + #[test] + fn automatic_credit_keys_skip_a_key_platform_refuses() { + for (case, refused) in refused_credit_keys(Purpose::TRANSFER) { + let usable = key(2, Purpose::TRANSFER); + let qi = build_identity( + IdentityType::User, + vec![refused.clone(), usable.clone()], + vec![refused, usable], + ); + assert_eq!(qi.resolve_transfer_signing_key(None), Ok(2), "{case}"); + assert_eq!(qi.transfer_signing_key().map(|k| k.id()), Some(2), "{case}"); + assert_eq!(qi.resolve_withdrawal_signing_key(None), Ok(2), "{case}"); + assert_eq!( + qi.withdrawal_signing_key().map(|k| k.id()), + Some(2), + "{case}" + ); + } + } + + /// A masternode whose TRANSFER key Platform refuses withdraws with its + /// OWNER key instead. + #[test] + fn a_masternode_withdrawal_falls_back_to_owner_past_a_refused_transfer_key() { + for (case, refused) in refused_credit_keys(Purpose::TRANSFER) { + let owner = key(2, Purpose::OWNER); + let qi = build_identity( + IdentityType::Masternode, + vec![refused.clone(), owner.clone()], + vec![refused, owner], + ); + assert_eq!(qi.resolve_withdrawal_signing_key(None), Ok(2), "{case}"); + } + } + + /// A user identity never withdraws with an OWNER key, as before. + #[test] + fn a_user_withdrawal_never_takes_an_owner_key() { + let owner = key(1, Purpose::OWNER); + let qi = build_identity(IdentityType::User, vec![owner.clone()], vec![owner]); + assert!(qi.withdrawal_signing_key().is_none()); + assert_eq!(qi.withdrawal_key_status(), SigningKeyStatus::NoneHeld); + } + + /// The transfer and withdrawal gates open exactly when the automatic + /// choice picks a key, and tell a held-but-unusable key apart from none. + #[test] + fn the_credit_key_gates_open_exactly_when_a_key_is_picked() { + for (case, refused) in refused_credit_keys(Purpose::TRANSFER) { + let qi = build_identity(IdentityType::User, vec![refused.clone()], vec![refused]); + assert_eq!( + qi.transfer_key_status(), + SigningKeyStatus::HeldButUnusable, + "{case}" + ); + assert_eq!( + qi.withdrawal_key_status(), + SigningKeyStatus::HeldButUnusable, + "{case}" + ); + assert!(!qi.can_attempt_withdrawal(UserRole::Developer), "{case}"); + } + + let public_only = + build_identity(IdentityType::User, vec![key(1, Purpose::TRANSFER)], vec![]); + assert_eq!( + public_only.transfer_key_status(), + SigningKeyStatus::NoneHeld + ); + assert_eq!( + public_only.withdrawal_key_status(), + SigningKeyStatus::NoneHeld + ); + + let usable = key(1, Purpose::TRANSFER); + let qi = build_identity(IdentityType::User, vec![usable.clone()], vec![usable]); + assert_eq!(qi.transfer_key_status(), SigningKeyStatus::Usable); + assert_eq!(qi.withdrawal_key_status(), SigningKeyStatus::Usable); + assert!(qi.can_attempt_withdrawal(UserRole::Everyday)); + } + fn payout_key(id: KeyID, hash: [u8; 20]) -> IdentityPublicKey { let mut k = key(id, Purpose::TRANSFER); k.set_key_type(KeyType::ECDSA_HASH160); @@ -3243,6 +3726,719 @@ mod decode_limit_tests { } } +/// Protocol version 14 added `IdentityPublicKey::V1` (key limits) and +/// `ContractBounds::ContractGroup`. Stored identity blobs carry both enums, so +/// blobs written before the bump must decode unchanged and the new variants +/// must round-trip. +#[cfg(test)] +mod protocol_v14_compat_tests { + use super::*; + + use crate::model::identity_key_usability::SigningScope; + use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; + use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + /// Upstream platform-wallet's `usable_authentication_key` is the reference + /// order: every unlimited key at any accepted level comes before any + /// limited key, then key id order. A delegated (budgeted) HIGH key is not + /// spent while an unlimited CRITICAL key is held. + #[test] + fn a_document_action_prefers_an_unlimited_key_at_any_accepted_level() { + use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; + let contract = dashpay_contract(); + let identity = identity_holding(&[ + auth_key(1, SecurityLevel::CRITICAL), + auth_key(2, SecurityLevel::HIGH).with_limits(Some(5_000), None), + ]); + assert_eq!( + identity + .dashpay_signing_key(&contract, "contactRequest") + .map(|key| key.id()), + Some(1), + "the unlimited CRITICAL key, not the budgeted HIGH key" + ); + + let identity = identity_holding(&[ + auth_key(1, SecurityLevel::CRITICAL), + auth_key(2, SecurityLevel::HIGH), + ]); + assert_eq!( + identity + .dashpay_signing_key(&contract, "contactRequest") + .map(|key| key.id()), + Some(1), + "key id order among unlimited keys, whatever their level" + ); + } + + /// Regression: the automatic choice took any key with a private half + /// filed, so an unlimited key filed under a wallet this device does not + /// hold, or in the legacy encrypted form, won over a usable limited key, + /// and signing then failed instead of falling back to the usable key. + #[test] + fn automatic_choice_skips_a_key_whose_private_half_cannot_be_obtained() { + use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; + use crate::model::qualified_identity::encrypted_key_storage::{ + PrivateKeyData, WalletDerivationPath, + }; + let unlimited = auth_key(1, SecurityLevel::HIGH); + let limited = auth_key(2, SecurityLevel::HIGH).with_limits(Some(5_000), None); + for unobtainable in [ + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: [0x7D; 32], + derivation_path: Default::default(), + }), + PrivateKeyData::Encrypted(vec![0; 48]), + ] { + let mut identity = identity_holding(&[unlimited.clone(), limited.clone()]); + identity.private_keys.insert_at( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + (unlimited.clone().into(), unobtainable.clone()), + ); + assert_eq!( + identity + .dashpay_signing_key(&dashpay_contract(), "contactRequest") + .map(|key| key.id()), + Some(2), + "{unobtainable:?}" + ); + } + } + + /// Regression: the contactRequest and contactInfo key choices admitted + /// MEDIUM keys, which the document types refuse, so an unlimited MEDIUM + /// key beat a held limited HIGH key and the write was refused. + #[test] + fn a_dashpay_document_is_signed_with_a_key_its_document_type_accepts() { + use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; + let limited_high = auth_key(1, SecurityLevel::HIGH).with_limits(Some(5_000), None); + let identity = identity_holding(&[limited_high, auth_key(2, SecurityLevel::MEDIUM)]); + for name in ["contactRequest", "contactInfo"] { + assert_eq!( + identity + .dashpay_signing_key(&dashpay_contract(), name) + .map(|key| key.id()), + Some(1), + "{name}" + ); + } + } + + /// DashPay documents are signed with an ECDSA key, as upstream + /// platform-wallet's DashPay writes require; a BLS or EdDSA key is + /// skipped even at an accepted level. + #[test] + fn a_dashpay_document_is_signed_with_an_ecdsa_key() { + use crate::context::test_support::{auth_key, dashpay_contract, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + + let typed = |id: KeyID, key_type: KeyType, len: usize| -> IdentityPublicKey { + IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type, + read_only: false, + data: vec![2; len].into(), + disabled_at: None, + } + .into() + }; + let contract = dashpay_contract(); + let identity = identity_holding(&[ + typed(1, KeyType::BLS12_381, 48), + typed(2, KeyType::EDDSA_25519_HASH160, 20), + auth_key(3, SecurityLevel::HIGH), + ]); + for name in ["contactRequest", "contactInfo"] { + assert_eq!( + identity + .dashpay_signing_key(&contract, name) + .map(|key| key.id()), + Some(3), + "{name}" + ); + } + + let identity = identity_holding(&[typed(1, KeyType::ECDSA_HASH160, 20)]); + assert_eq!( + identity + .dashpay_signing_key(&contract, "contactRequest") + .map(|key| key.id()), + Some(1), + "an ECDSA_HASH160 key signs like an ECDSA_SECP256K1 key" + ); + } + + /// The bundled v0.9.3 profile: real identity rows written by DET v0.9.3. + const V0_9_3_DATA_SQL: &str = + include_str!("../../../tests/migration-fixtures/v0.9.3-public-identities/data.sql"); + + /// The stored blob of the v0.9.3 user identity `public-dpns-user`. + fn v0_9_3_user_blob() -> Vec { + const ROW: &str = "INSERT INTO \"identity\" VALUES(X'3B9DA97EBC06C07C0C0C48695EA5D36CAECE9E19E4944AAE5548691D9A80FAE5',X'"; + let start = V0_9_3_DATA_SQL.find(ROW).expect("the user identity row") + ROW.len(); + let len = V0_9_3_DATA_SQL[start..] + .find('\'') + .expect("the end of the blob literal"); + hex::decode(&V0_9_3_DATA_SQL[start..start + len]).expect("a hex blob") + } + + #[test] + fn a_blob_written_before_protocol_v14_still_decodes_with_v0_keys() { + let qi = QualifiedIdentity::from_bytes(&v0_9_3_user_blob()).expect("decodes"); + + let keys = qi.identity.public_keys(); + assert_eq!(keys.len(), 6, "as the v0.9.3 fixture expectations list"); + assert!( + keys.values().all(|k| matches!(k, IdentityPublicKey::V0(_))), + "every pre-v14 key decodes as V0" + ); + assert!(keys.values().all(|k| !k.has_limits())); + let bounds: Vec<_> = keys.values().filter_map(|k| k.contract_bounds()).collect(); + assert_eq!( + bounds.len(), + 2, + "the DashPay encryption and decryption keys" + ); + assert!(bounds.iter().all(|b| matches!( + b, + ContractBounds::SingleContractDocumentType { document_type_name, .. } + if document_type_name == "contactRequest" + ))); + + let reencoded = QualifiedIdentity::from_bytes(&qi.to_bytes()).expect("re-decodes"); + assert_eq!(reencoded.identity, qi.identity, "a re-save keeps every key"); + } + + #[test] + fn keys_with_limits_and_group_bounds_round_trip_through_the_blob() { + let mut qi = QualifiedIdentity::from_bytes(&v0_9_3_user_blob()).expect("decodes"); + let pv = PlatformVersion::latest(); + let limited = IdentityPublicKey::random_key(10, Some(1), pv) + .with_limits(Some(5_000_000), Some(1_900_000_000_000)); + let grouped = match IdentityPublicKey::random_key(11, Some(2), pv) { + IdentityPublicKey::V0(mut v0) => { + v0.contract_bounds = Some(ContractBounds::ContractGroup { + id: Identifier::from([7u8; 32]), + }); + IdentityPublicKey::V0(v0) + } + other => other, + }; + let mut keys = qi.identity.public_keys().clone(); + keys.insert(10, limited.clone()); + keys.insert(11, grouped.clone()); + qi.identity.set_public_keys(keys); + + let decoded = QualifiedIdentity::from_bytes(&qi.to_bytes()).expect("decodes"); + let keys = decoded.identity.public_keys(); + assert_eq!(keys.get(&10), Some(&limited)); + assert_eq!(keys[&10].total_budget(), Some(5_000_000)); + assert_eq!(keys[&10].expires_at(), Some(1_900_000_000_000)); + assert_eq!(keys.get(&11), Some(&grouped)); + } + + /// A key extended on the network but stored here as its expired copy is + /// judged by the live key: no "expired" caveat (the key chooser's view). + #[test] + fn an_extended_key_is_not_reported_expired_from_its_stale_copy() { + use crate::model::identity_key_usability::{KeyCaveat, key_caveats}; + let pv = PlatformVersion::latest(); + let stale = IdentityPublicKey::random_key(7, Some(7), pv).with_limits(None, Some(1)); + let extended = stale.clone().with_limits(None, Some(u64::MAX)); + let mut qi = QualifiedIdentity::from_bytes(&v0_9_3_user_blob()).expect("decodes"); + qi.identity + .set_public_keys(BTreeMap::from([(7, extended.clone())])); + + let live = qi.live_public_key(&stale); + assert_eq!(live, &extended); + let caveats = key_caveats(live, SigningScope::NonBatch, 1_000); + assert!( + !caveats + .iter() + .any(|caveat| matches!(caveat, KeyCaveat::Expired { .. })), + "{caveats:?}" + ); + assert!( + key_caveats(&stale, SigningScope::NonBatch, 1_000) + .iter() + .any(|caveat| matches!(caveat, KeyCaveat::Expired { .. })), + "the stale copy alone would say expired" + ); + } + + /// Platform accepts a stronger key than a document type requires, so a + /// CRITICAL key signs a HIGH document type when no HIGH key is held; a + /// MASTER key never does. + #[test] + fn document_signing_key_accepts_a_stronger_key_than_required() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + + let pv = PlatformVersion::latest(); + let dpns = dash_sdk::dpp::system_data_contracts::load_system_data_contract( + dash_sdk::dpp::system_data_contracts::SystemDataContract::DPNS, + pv, + ) + .expect("the DPNS contract"); + let preorder: DocumentTypeRef = dpns.document_type_for_name("preorder").expect("preorder"); + assert_eq!(preorder.security_level_requirement(), SecurityLevel::HIGH); + + let qi = identity_holding(&[ + auth_key(1, SecurityLevel::MASTER), + auth_key(2, SecurityLevel::CRITICAL), + ]); + let scope = SigningScope::ContractWide { + contract_id: dpns.id(), + }; + assert_eq!( + qi.document_signing_key(scope, &preorder, DPNS_SIGNING_KEY_TYPES) + .map(|k| k.id()), + Some(2), + "the CRITICAL key, never the MASTER key" + ); + + // Every accepted level is one tier: the lowest key id wins. + let qi = identity_holding(&[ + auth_key(1, SecurityLevel::MASTER), + auth_key(2, SecurityLevel::CRITICAL), + auth_key(3, SecurityLevel::HIGH), + ]); + assert_eq!( + qi.document_signing_key(scope, &preorder, DPNS_SIGNING_KEY_TYPES) + .map(|k| k.id()), + Some(2), + "key id order among accepted levels, as upstream orders them" + ); + } + + /// Regression: a token transfer took only a CRITICAL AUTHENTICATION key, + /// so an identity holding only a CRITICAL TRANSFER key, which DPP also + /// accepts for a batch of one token transfer or claim, could not send. + #[test] + fn a_single_token_transition_falls_back_to_a_transfer_key() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let contract_id = Identifier::new([0x70; 32]); + let transfer = |id: KeyID, level| { + let mut key = auth_key(id, level); + key.set_purpose(Purpose::TRANSFER); + key + }; + + let qi = identity_holding(&[transfer(1, SecurityLevel::CRITICAL)]); + assert_eq!( + qi.single_token_transition_key(contract_id).map(|k| k.id()), + Some(1) + ); + assert!(qi.holds_single_token_transition_key()); + + let qi = identity_holding(&[ + transfer(1, SecurityLevel::CRITICAL), + auth_key(2, SecurityLevel::CRITICAL), + ]); + assert_eq!( + qi.single_token_transition_key(contract_id).map(|k| k.id()), + Some(2), + "an authentication key first" + ); + + let qi = identity_holding(&[ + transfer(1, SecurityLevel::HIGH), + auth_key(2, SecurityLevel::HIGH), + ]); + assert!(qi.single_token_transition_key(contract_id).is_none()); + assert!(!qi.holds_single_token_transition_key()); + } + + /// Regression: the single token transition key took any key type, so a + /// CRITICAL EdDSA authentication key, which Platform refuses, shadowed a + /// usable CRITICAL TRANSFER key, and the gate counted it as usable. + #[test] + fn a_single_token_transition_skips_a_key_platform_cannot_verify() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let mut eddsa = auth_key(1, SecurityLevel::CRITICAL); + eddsa.set_key_type(KeyType::EDDSA_25519_HASH160); + let mut transfer = auth_key(2, SecurityLevel::CRITICAL); + transfer.set_purpose(Purpose::TRANSFER); + + let qi = identity_holding(&[eddsa.clone(), transfer]); + assert_eq!( + qi.single_token_transition_key(Identifier::new([0x70; 32])) + .map(|k| k.id()), + Some(2) + ); + assert!(!identity_holding(&[eddsa]).holds_single_token_transition_key()); + + let mut bls = auth_key(3, SecurityLevel::CRITICAL); + bls.set_key_type(KeyType::BLS12_381); + assert!( + identity_holding(&[bls]).holds_single_token_transition_key(), + "Platform verifies BLS signatures" + ); + } + + /// Regression: the token transfer and claim gate counted any filed + /// CRITICAL key, while the transition signs only with a live key valid + /// for the contract whose private half this device can read, so an + /// identity holding only an expired, elsewhere-bound or unreadable key got + /// a form it could not send. The gate opens exactly when the key choice + /// picks a key. + #[test] + fn the_single_token_gate_opens_exactly_when_a_key_is_picked() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + + let contract_id = Identifier::new([0x70; 32]); + let critical = |id| auth_key(id, SecurityLevel::CRITICAL); + let expired = identity_holding(&[critical(1).with_limits(None, Some(1))]); + let elsewhere = identity_holding(&[bound_to( + critical(1), + ContractBounds::SingleContract { + id: Identifier::new([0x09; 32]), + }, + )]); + let mut unreadable = identity_holding(&[critical(1)]); + unreadable.private_keys.insert_at( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + ( + QualifiedIdentityPublicKey { + identity_public_key: critical(1), + in_wallet_at_derivation_path: None, + }, + PrivateKeyData::Encrypted(vec![1; 48]), + ), + ); + for (case, qi) in [ + ("expired", &expired), + ("bound elsewhere", &elsewhere), + ("not readable here", &unreadable), + ] { + assert!( + qi.single_token_transition_key(contract_id).is_none(), + "{case}" + ); + assert_eq!( + qi.single_token_transition_key_status(contract_id), + SigningKeyStatus::HeldButUnusable, + "{case}" + ); + } + + let bound_here = identity_holding(&[bound_to( + critical(1), + ContractBounds::SingleContract { id: contract_id }, + )]); + assert!( + bound_here + .single_token_transition_key(contract_id) + .is_some() + ); + assert_eq!( + bound_here.single_token_transition_key_status(contract_id), + SigningKeyStatus::Usable + ); + assert_eq!( + identity_holding(&[auth_key(1, SecurityLevel::HIGH)]) + .single_token_transition_key_status(contract_id), + SigningKeyStatus::NoneHeld + ); + } + + /// Regression: the generic token action forms (mint, burn, pricing, + /// purchase, configuration…) opened whenever a CRITICAL AUTHENTICATION key + /// was filed, while the action signs only with a live key valid for the + /// token's contract whose private half this device can obtain. The gate + /// opens exactly when the action's key choice picks a key. + #[test] + fn the_token_action_gate_opens_exactly_when_a_key_is_picked() { + use crate::context::test_support::{ + auth_key, bound_to, hold_as_legacy_encrypted, hold_as_wallet_reference, + identity_holding, + }; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let contract_id = Identifier::new([0x70; 32]); + let critical = |id| auth_key(id, SecurityLevel::CRITICAL); + let expired = identity_holding(&[critical(1).with_limits(None, Some(1))]); + let elsewhere = identity_holding(&[bound_to( + critical(1), + ContractBounds::SingleContract { + id: Identifier::new([0x09; 32]), + }, + )]); + let mut legacy = identity_holding(&[critical(1)]); + hold_as_legacy_encrypted(&mut legacy, 1); + let mut walletless = identity_holding(&[critical(1)]); + hold_as_wallet_reference(&mut walletless, 1, [0x5A; 32]); + let mut transfer_only = critical(1); + transfer_only.set_purpose(Purpose::TRANSFER); + let transfer_only = identity_holding(&[transfer_only]); + + for (case, qi) in [ + ("expired", &expired), + ("bound elsewhere", &elsewhere), + ("legacy encrypted", &legacy), + ("wallet not here", &walletless), + ] { + assert!(qi.token_action_key(contract_id).is_none(), "{case}"); + assert_eq!( + qi.token_action_key_status(contract_id), + SigningKeyStatus::HeldButUnusable, + "{case}" + ); + } + + let bound_here = identity_holding(&[bound_to( + critical(1), + ContractBounds::SingleContract { id: contract_id }, + )]); + assert!(bound_here.token_action_key(contract_id).is_some()); + assert_eq!( + bound_here.token_action_key_status(contract_id), + SigningKeyStatus::Usable + ); + for (case, qi) in [ + ( + "a HIGH key", + identity_holding(&[auth_key(1, SecurityLevel::HIGH)]), + ), + ("a transfer key", transfer_only), + ] { + assert_eq!( + qi.token_action_key_status(contract_id), + SigningKeyStatus::NoneHeld, + "{case}: a token action signs only with an authentication key" + ); + } + } + + /// Regression: an identity update signed with the first filed MASTER key, + /// so after a master rotation the disabled predecessor shadowed its + /// usable replacement and Platform refused the update. + #[test] + fn an_identity_update_signs_with_the_live_master_key() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + let mut predecessor = auth_key(0, SecurityLevel::MASTER); + predecessor.set_disabled_at(1); + let qi = identity_holding(&[predecessor, auth_key(1, SecurityLevel::MASTER)]); + assert_eq!( + qi.identity_update_signing_key().map(|key| key.id()), + Some(1) + ); + + let mut node = qi.clone(); + node.identity_type = IdentityType::Masternode; + assert!(node.identity_update_signing_key().is_none()); + } + + /// Regression: a key bound to a contract group, which only Platform can + /// match to the token's contract, is never picked automatically, and the + /// token gates then closed before the Advanced chooser that offers it. + /// Such a key leaves the choice to the user; an expired one does not. + #[test] + fn a_contract_group_key_leaves_the_token_key_to_the_user() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let contract_id = Identifier::new([0x70; 32]); + let group = |key| { + bound_to( + key, + ContractBounds::ContractGroup { + id: Identifier::new([0x6A; 32]), + }, + ) + }; + let auth = group(auth_key(1, SecurityLevel::CRITICAL)); + let mut transfer = auth.clone(); + transfer.set_purpose(Purpose::TRANSFER); + + let qi = identity_holding(std::slice::from_ref(&auth)); + assert!(qi.token_action_key(contract_id).is_none()); + assert_eq!( + qi.token_action_key_status(contract_id), + SigningKeyStatus::ManualChoiceOnly + ); + for (case, key) in [("authentication", auth), ("transfer", transfer)] { + let qi = identity_holding(std::slice::from_ref(&key)); + assert!(qi.single_token_transition_key(contract_id).is_none()); + assert_eq!( + qi.single_token_transition_key_status(contract_id), + SigningKeyStatus::ManualChoiceOnly, + "{case}" + ); + let expired = identity_holding(&[key.with_limits(None, Some(1))]); + assert_eq!( + expired.single_token_transition_key_status(contract_id), + SigningKeyStatus::HeldButUnusable, + "{case}, expired" + ); + } + } + + /// Regression: the automatic username registration key was taken of any + /// key type, so a BLS or EdDSA key with a lower id won over an ECDSA key. + #[test] + fn a_username_registration_key_is_an_ecdsa_key() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let dpns = dash_sdk::dpp::system_data_contracts::load_system_data_contract( + dash_sdk::dpp::system_data_contracts::SystemDataContract::DPNS, + PlatformVersion::latest(), + ) + .expect("the DPNS contract"); + let typed = |id: KeyID, key_type: KeyType| { + let mut key = auth_key(id, SecurityLevel::HIGH); + key.set_key_type(key_type); + key + }; + let qi = identity_holding(&[ + typed(1, KeyType::BLS12_381), + typed(2, KeyType::EDDSA_25519_HASH160), + typed(3, KeyType::ECDSA_SECP256K1), + ]); + assert_eq!(qi.username_registration_key(&dpns).map(|k| k.id()), Some(3)); + + let qi = identity_holding(&[typed(1, KeyType::BLS12_381)]); + assert!(qi.username_registration_key(&dpns).is_none()); + } + + /// Regression: the automatic username registration key met only the + /// `preorder` document's requirement, so when `domain` asked for a + /// stronger key the registration signed the second document with a key + /// Platform refuses. Both documents' requirement decides, whichever is + /// stricter. + #[test] + fn a_username_registration_key_meets_both_registration_documents() { + use crate::context::test_support::{auth_key, dpns_contract_requiring, identity_holding}; + use crate::model::dpns_usernames::{dpns_security_levels, dpns_signing_requirement}; + + let qi = identity_holding(&[ + auth_key(1, SecurityLevel::HIGH), + auth_key(2, SecurityLevel::CRITICAL), + ]); + for (preorder, domain) in [ + (SecurityLevel::HIGH, SecurityLevel::CRITICAL), + (SecurityLevel::CRITICAL, SecurityLevel::HIGH), + ] { + let dpns = dpns_contract_requiring(preorder, domain); + let case = format!("preorder {preorder:?}, domain {domain:?}"); + assert_eq!( + qi.username_registration_key(&dpns).map(|k| k.id()), + Some(2), + "{case}" + ); + assert_eq!( + dpns_security_levels(dpns_signing_requirement(&dpns)), + vec![SecurityLevel::CRITICAL], + "{case}: the Advanced chooser offers the same levels" + ); + } + } + + /// The DPNS levels are the ones the explicit-key check accepts: CRITICAL + /// up to the requirement, never MASTER. + #[test] + fn dpns_levels_match_the_explicit_key_check() { + use crate::context::test_support::auth_key; + use crate::model::dpns_usernames::{chosen_key_can_sign_documents, dpns_security_levels}; + + for required in SecurityLevel::full_range() { + let levels = dpns_security_levels(required); + for level in SecurityLevel::full_range() { + assert_eq!( + levels.contains(&level), + chosen_key_can_sign_documents(&auth_key(1, level), required), + "required {required:?}, key {level:?}" + ); + } + } + } + + /// The document signing key skips a key Platform would refuse (expired, + /// bound elsewhere) and prefers an unlimited key over a limited one. + #[test] + fn document_signing_key_picks_a_usable_unlimited_key() { + use crate::context::test_support::{bound_to, identity_holding}; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + + let pv = PlatformVersion::latest(); + let dpns = dash_sdk::dpp::system_data_contracts::load_system_data_contract( + dash_sdk::dpp::system_data_contracts::SystemDataContract::DPNS, + pv, + ) + .expect("the DPNS contract"); + let preorder: DocumentTypeRef = dpns.document_type_for_name("preorder").expect("preorder"); + let level = preorder.security_level_requirement(); + + let auth_key = |id: KeyID| crate::context::test_support::auth_key(id, level); + let expired = auth_key(1).with_limits(None, Some(1)); + let elsewhere = bound_to( + auth_key(2), + ContractBounds::SingleContract { + id: Identifier::from([9u8; 32]), + }, + ); + let limited = auth_key(3).with_limits(Some(1_000), None); + let plain = auth_key(4); + let mut qi = identity_holding(&[expired, elsewhere, limited, plain]); + let scope = SigningScope::ContractWide { + contract_id: dpns.id(), + }; + assert_eq!( + qi.document_signing_key(scope, &preorder, DPNS_SIGNING_KEY_TYPES) + .map(|k| k.id()), + Some(4), + "the unlimited, unbound, live key" + ); + + let mut keys = qi.identity.public_keys().clone(); + keys.remove(&4); + qi.identity.set_public_keys(keys); + assert_eq!( + qi.document_signing_key(scope, &preorder, DPNS_SIGNING_KEY_TYPES) + .map(|k| k.id()), + Some(3), + "a limited key when it is the only usable one" + ); + + // The stored snapshot of key 3 is judged by the live key. + let mut keys = qi.identity.public_keys().clone(); + let raised = keys[&3].clone().with_limits(Some(9_000), None); + let snapshot = keys[&3].clone(); + keys.insert(3, raised.clone()); + qi.identity.set_public_keys(keys); + assert_eq!(qi.live_public_key(&snapshot), &raised); + let unknown = auth_key(42); + assert_eq!(qi.live_public_key(&unknown), &unknown); + + // A held limited key beats an unlimited key whose private half is + // not on this device. + let mut keys = qi.identity.public_keys().clone(); + keys.insert(5, auth_key(5)); + qi.identity.set_public_keys(keys); + assert_eq!( + qi.document_signing_key(scope, &preorder, DPNS_SIGNING_KEY_TYPES) + .map(|k| k.id()), + Some(3), + "the unlimited key 5 cannot be signed with here" + ); + } +} #[cfg(test)] mod identity_display_name_tests { use super::*; diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index 984c60bc4..d606f5ead 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -2330,7 +2330,11 @@ pub(crate) mod test_support { /// A minimal open wallet on [`test_seed`], with its BIP44 master public key /// derived and every optional field left empty. pub(crate) fn open_wallet() -> Wallet { - let seed = test_seed(); + open_wallet_on(test_seed()) + } + + /// [`open_wallet`] on `seed`, for a test that needs two distinct wallets. + pub(crate) fn open_wallet_on(seed: [u8; 64]) -> Wallet { let network = Network::Testnet; // Derive master private key from seed diff --git a/src/ui/contracts_documents/document_action_screen.rs b/src/ui/contracts_documents/document_action_screen.rs index 06155c93c..28549d1d6 100644 --- a/src/ui/contracts_documents/document_action_screen.rs +++ b/src/ui/contracts_documents/document_action_screen.rs @@ -7,6 +7,7 @@ use crate::backend_task::{BackendTask, document::DocumentTask}; use crate::context::AppContext; use crate::model::document::new_document_for_creation; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; @@ -18,12 +19,12 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::helpers::{ TransactionType, add_contract_doc_type_chooser_with_filtering, add_key_chooser_with_doc_type, show_success_screen_with_info, }; -use crate::ui::identity::get_selected_wallet; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::state::PendingRequest; use crate::ui::theme::{ComponentStyles, DashColors}; use base64::Engine; @@ -173,6 +174,10 @@ pub struct DocumentActionScreen { pending_broadcast: PendingRequest, } +/// Shown when the selected identity holds no key this device can sign the +/// chosen document or contract action with. +pub(super) const NO_SIGNING_KEY_NOTICE: &str = "This identity has no key on this device that can sign this action. Add or load a suitable key from the identity's keys, or select another identity."; + impl DocumentActionScreen { pub fn new( app_context: Arc, @@ -381,12 +386,33 @@ impl DocumentActionScreen { .as_ref() .map(|d| d.name().to_owned()); if contract_changed || doc_type_changed { - self.clear_selection_state(); + self.signing_scope_changed(); } ui.add_space(10.0); } + /// The wallet holding the key the action is signed with, for the unlock + /// prompt. + fn signing_wallet(&self) -> Option>> { + let identity = self.selected_identity.as_ref()?; + wallet_for_signing_key(identity, self.selected_key.as_ref()) + } + + /// The contract or document type changed: what was fetched for the old + /// one no longer applies, and the key is picked again for the new scope + /// (a key bound to the old contract or type cannot sign in the new one). + fn signing_scope_changed(&mut self) { + self.clear_selection_state(); + if let Some(identity) = &self.selected_identity { + self.selected_key = auto_selected_document_key( + identity, + self.selected_contract.as_ref(), + self.selected_document_type.as_ref(), + ); + } + } + fn render_identity_and_key_selection(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { ui.heading("2. Select an identity:"); @@ -419,29 +445,13 @@ impl DocumentActionScreen { self.no_documents_found = false; self.fetched_documents.clear(); if let Some(identity) = &self.selected_identity { - // Auto-select a suitable key for document actions - // Note: MASTER keys cannot be used for document operations, - // only MEDIUM, HIGH, or CRITICAL security levels are allowed - use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; - self.selected_key = identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - [ - SecurityLevel::CRITICAL, - SecurityLevel::HIGH, - SecurityLevel::MEDIUM, - ] - .into(), - KeyType::all_key_types().into(), - false, - ) - .cloned(); + self.selected_key = auto_selected_document_key( + identity, + self.selected_contract.as_ref(), + self.selected_document_type.as_ref(), + ); - // Update wallet - self.wallet = get_selected_wallet(identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + self.wallet = self.signing_wallet(); self.wallet_open_attempted = false; } else { self.selected_key = None; @@ -461,6 +471,10 @@ impl DocumentActionScreen { &mut self.selected_key, TransactionType::DocumentAction, self.selected_document_type.as_ref(), + signing_scope( + self.selected_contract.as_ref(), + self.selected_document_type.as_ref(), + ), ); } } @@ -2020,7 +2034,15 @@ impl DocumentActionScreen { // Step 2: Identity and Key Selection self.render_identity_and_key_selection(ui); - if self.selected_identity.is_none() || self.selected_key.is_none() { + if self.selected_identity.is_none() { + return action; + } + if self.selected_key.is_none() { + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new(NO_SIGNING_KEY_NOTICE) + .color(DashColors::warning_color(dark_mode)), + ); return action; } @@ -2028,11 +2050,8 @@ impl DocumentActionScreen { ui.add_space(10.0); // Wallet unlock - if let Some(selected_identity) = &self.selected_identity { - let new_wallet = - get_selected_wallet(selected_identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + if self.selected_identity.is_some() { + let new_wallet = self.signing_wallet(); let wallet_changed = match (&self.wallet, &new_wallet) { (Some(a), Some(b)) => !Arc::ptr_eq(a, b), (None, None) => false, @@ -2082,6 +2101,50 @@ impl DocumentActionScreen { } } +/// The key to sign a document action with when the user has not picked one: +/// a live AUTHENTICATION key this device can sign with, at a security level +/// the document type accepts, within the contract and document type's scope. +/// Keys are ranked in upstream platform-wallet's order, as for every document +/// key ([`QualifiedIdentity::signing_key_now`]). +fn auto_selected_document_key( + identity: &QualifiedIdentity, + contract: Option<&QualifiedContract>, + document_type: Option<&DocumentType>, +) -> Option { + // The levels the document type accepts, so the preference + // for unlimited keys only ever chooses among keys Platform accepts. + let levels = crate::ui::helpers::compute_allowed_security_levels( + TransactionType::DocumentAction, + document_type, + ); + identity + .signing_key_now(KeyRequirements::new( + dash_sdk::dpp::identity::Purpose::AUTHENTICATION, + &levels, + signing_scope(contract, document_type), + )) + .cloned() +} + +/// The contract-bounds scope of a document action on `document_type` of +/// `contract`. Before a contract is chosen nothing is known about the target, +/// so only keys that can sign anywhere (unbound keys) qualify. +fn signing_scope<'a>( + contract: Option<&QualifiedContract>, + document_type: Option<&'a DocumentType>, +) -> SigningScope<'a> { + match (contract, document_type) { + (Some(contract), Some(document_type)) => SigningScope::Document { + contract_id: contract.contract.id(), + document_type_name: document_type.name().as_str(), + }, + (Some(contract), None) => SigningScope::ContractWide { + contract_id: contract.contract.id(), + }, + (None, _) => SigningScope::NonBatch, + } +} + #[cfg(test)] mod tests { use super::*; @@ -2105,6 +2168,153 @@ mod tests { ).unwrap() } + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + + use crate::context::test_support::identity_holding; + + /// An AUTHENTICATION key at `level`, optionally bound to `bounds`. + fn auth_key( + id: u32, + level: dash_sdk::dpp::identity::SecurityLevel, + bounds: Option< + dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds, + >, + ) -> IdentityPublicKey { + let key = crate::context::test_support::auth_key(id, level); + match bounds { + Some(bounds) => crate::context::test_support::bound_to(key, bounds), + None => key, + } + } + + /// The DPNS contract, whose document types require a HIGH key. + fn dpns() -> QualifiedContract { + use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; + use dash_sdk::dpp::version::PlatformVersion; + QualifiedContract { + contract: load_system_data_contract( + SystemDataContract::DPNS, + PlatformVersion::latest(), + ) + .expect("the DPNS contract"), + alias: None, + } + } + + fn owned_document_type(contract: &QualifiedContract, name: &str) -> DocumentType { + contract + .contract + .document_type_for_name(name) + .expect("document type") + .to_owned_document_type() + } + + /// Regression: automatic selection admitted every non-master level and + /// preferred unlimited keys, so an unlimited MEDIUM key beat a limited key + /// at the level the document type requires, and Platform refused it. + #[test] + fn auto_selection_honours_the_document_types_security_level() { + use dash_sdk::dpp::identity::SecurityLevel; + let contract = dpns(); + let domain = owned_document_type(&contract, "domain"); + let required = domain.security_level_requirement(); + assert_ne!( + required, + SecurityLevel::MEDIUM, + "the test needs a stricter type" + ); + + let limited = auth_key(1, required, None).with_limits(Some(5_000), None); + let weaker_unlimited = auth_key(2, SecurityLevel::MEDIUM, None); + let identity = identity_holding(&[limited, weaker_unlimited]); + + let chosen = auto_selected_document_key(&identity, Some(&contract), Some(&domain)); + assert_eq!(chosen.map(|key| key.id()), Some(1)); + } + + /// Automatic selection follows upstream platform-wallet's order: an + /// unlimited key at any accepted level beats a limited key, so a budgeted + /// HIGH key is not spent while an unlimited CRITICAL key is held. + #[test] + fn auto_selection_prefers_an_unlimited_key_at_any_accepted_level() { + use dash_sdk::dpp::identity::SecurityLevel; + let contract = dpns(); + let domain = owned_document_type(&contract, "domain"); + let required = domain.security_level_requirement(); + assert_eq!(required, SecurityLevel::HIGH, "precondition: a HIGH type"); + let identity = identity_holding(&[ + auth_key(1, SecurityLevel::CRITICAL, None), + auth_key(2, SecurityLevel::HIGH, None).with_limits(Some(5_000), None), + ]); + + let chosen = auto_selected_document_key(&identity, Some(&contract), Some(&domain)); + assert_eq!(chosen.map(|key| key.id()), Some(1)); + } + + /// Regression: the unlock prompt looked for a DPNS signing key, so an + /// identity whose only key is bound to the action's contract got no + /// wallet to unlock although that key signs the action. + #[test] + fn the_unlock_wallet_follows_the_key_that_signs() { + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let contract = QualifiedContract { + contract: crate::context::test_support::dashpay_contract(), + alias: None, + }; + let (identity, wallet) = crate::context::test_support::identity_with_bound_wallet_key( + ContractBounds::SingleContract { + id: contract.contract.id(), + }, + ); + + let mut screen = DocumentActionScreen::new(context, None, DocumentActionType::Create); + screen.selected_document_type = Some(owned_document_type(&contract, "contactInfo")); + screen.selected_contract = Some(contract); + screen.selected_identity = Some(identity); + screen.signing_scope_changed(); + assert_eq!(screen.selected_key.as_ref().map(|key| key.id()), Some(1)); + + let resolved = screen.signing_wallet(); + assert!(resolved.is_some_and(|resolved| Arc::ptr_eq(&resolved, &wallet))); + } + + /// Regression: a key picked for one document type survived a switch to a + /// type it is not bound to, and the action was signed with it anyway. + #[test] + fn changing_the_document_type_picks_the_key_again() { + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let contract = dpns(); + let domain = owned_document_type(&contract, "domain"); + let preorder = owned_document_type(&contract, "preorder"); + let domain_only = auth_key( + 1, + domain.security_level_requirement(), + Some(ContractBounds::SingleContractDocumentType { + id: contract.contract.id(), + document_type_name: "domain".to_string(), + }), + ); + let identity = identity_holding(&[domain_only]); + + let mut screen = DocumentActionScreen::new(context, None, DocumentActionType::Create); + screen.selected_contract = Some(contract.clone()); + screen.selected_document_type = Some(domain.clone()); + screen.selected_key = auto_selected_document_key(&identity, Some(&contract), Some(&domain)); + screen.selected_identity = Some(identity); + assert_eq!(screen.selected_key.as_ref().map(|key| key.id()), Some(1)); + + screen.selected_document_type = Some(preorder); + screen.signing_scope_changed(); + assert_eq!( + screen.selected_key, None, + "a key bound to domain cannot sign a preorder" + ); + } + /// The chooser keeps the document type when the contract changes. A type /// from another contract must not be built: the document id would commit /// to that contract while the transition targets the selected one. @@ -2853,4 +3063,35 @@ mod tests { Some(&Value::Text("changed".into())) ); } + + /// Regression: with an identity selected but no key that can sign the + /// action, the screen stopped below the identity selector with no word + /// of why (the key chooser is behind Advanced Options). + #[test] + fn an_identity_without_a_usable_key_is_told_why_nothing_follows() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let contract = dpns(); + let domain = owned_document_type(&contract, "domain"); + let mut screen = DocumentActionScreen::new(context, None, DocumentActionType::Delete); + screen.selected_contract = Some(contract); + screen.selected_document_type = Some(domain); + screen.selected_identity = Some(identity_holding(&[])); + assert!(screen.selected_key.is_none()); + + let mut harness = Harness::builder().build_ui_state( + |ui, screen: &mut DocumentActionScreen| { + screen.render_main_content(ui); + }, + screen, + ); + harness.run(); + assert!( + harness.query_by_label(NO_SIGNING_KEY_NOTICE).is_some(), + "the missing key is explained" + ); + } } diff --git a/src/ui/contracts_documents/register_contract_screen.rs b/src/ui/contracts_documents/register_contract_screen.rs index 14c43790d..9dda2c096 100644 --- a/src/ui/contracts_documents/register_contract_screen.rs +++ b/src/ui/contracts_documents/register_contract_screen.rs @@ -4,6 +4,7 @@ use crate::backend_task::FeeResult; use crate::backend_task::contract::ContractTask; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::identity_selector::IdentitySelector; @@ -15,7 +16,7 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; -use crate::ui::identity::get_selected_wallet; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{BackendTaskSuccessResult, MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Setters; @@ -74,26 +75,21 @@ impl RegisterDataContractScreen { }) .or_else(|| qualified_identities.first().cloned()); - let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None).unwrap_or(None) - } else { - None - }; - // Auto-select a suitable key for contract registration - use dash_sdk::dpp::identity::KeyType; let selected_key = selected_qualified_identity.as_ref().and_then(|identity| { identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + SigningScope::NonBatch, + )) .cloned() }); + let selected_wallet = selected_qualified_identity + .as_ref() + .and_then(|identity| wallet_for_signing_key(identity, selected_key.as_ref())); + let selected_identity_string = selected_qualified_identity .as_ref() .map(|qi| { @@ -463,25 +459,16 @@ impl ScreenLike for RegisterDataContractScreen { if response.changed() { if let Some(identity) = &self.selected_qualified_identity { // Auto-select a suitable key for contract registration - use dash_sdk::dpp::identity::KeyType; self.selected_key = identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - [SecurityLevel::HIGH, SecurityLevel::CRITICAL].into(), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + SigningScope::NonBatch, + )) .cloned(); - // Update wallet - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - ) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + self.selected_wallet = + wallet_for_signing_key(identity, self.selected_key.as_ref()); self.wallet_open_attempted = false; // Re-parse contract with new owner ID @@ -503,6 +490,7 @@ impl ScreenLike for RegisterDataContractScreen { identity, &mut self.selected_key, TransactionType::RegisterContract, + SigningScope::NonBatch, ); } } @@ -516,6 +504,13 @@ impl ScreenLike for RegisterDataContractScreen { } if self.selected_key.is_none() { + if self.selected_qualified_identity.is_some() { + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new(super::document_action_screen::NO_SIGNING_KEY_NOTICE) + .color(DashColors::warning_color(dark_mode)), + ); + } return AppAction::None; } @@ -584,3 +579,40 @@ impl ScreenLike for RegisterDataContractScreen { action } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::test_support::{auth_key, identity_holding}; + use egui_kittest::kittest::Queryable; + + /// Regression: an identity holding only a key that cannot sign (here an + /// expired one) passed the screen's gate, and the form stopped with no + /// word once the key choice found nothing. + #[test] + fn an_identity_without_a_usable_key_is_told_so() { + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(dir.path()); + let identity = + identity_holding(&[auth_key(1, SecurityLevel::CRITICAL).with_limits(None, Some(1))]); + let mut screen = RegisterDataContractScreen::new(&ctx); + screen.qualified_identities = vec![identity.clone()]; + screen.selected_qualified_identity = Some(identity); + screen.selected_key = None; + + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(1280.0, 900.0)) + .build_ui_state( + |ui, screen: &mut RegisterDataContractScreen| { + screen.ui(ui); + }, + screen, + ); + harness.run(); + assert!( + harness + .query_by_label(super::super::document_action_screen::NO_SIGNING_KEY_NOTICE) + .is_some() + ); + } +} diff --git a/src/ui/contracts_documents/update_contract_screen.rs b/src/ui/contracts_documents/update_contract_screen.rs index 03da6e2b2..5959def4f 100644 --- a/src/ui/contracts_documents/update_contract_screen.rs +++ b/src/ui/contracts_documents/update_contract_screen.rs @@ -4,6 +4,7 @@ use crate::backend_task::FeeResult; use crate::backend_task::contract::ContractTask; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; use crate::model::qualified_contract::QualifiedContract; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; @@ -16,7 +17,7 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; use crate::ui::helpers::{TransactionType, add_key_chooser}; -use crate::ui::identity::get_selected_wallet; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{BackendTaskSuccessResult, MessageType, ScreenLike}; use dash_sdk::dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; @@ -24,13 +25,12 @@ use dash_sdk::dpp::data_contract::conversion::json::DataContractJsonConversionMe use dash_sdk::dpp::data_contract::serialized_version::DataContractInSerializationFormat; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::version::TryFromPlatformVersioned; use dash_sdk::platform::{DataContract, IdentityPublicKey}; use eframe::egui::{self, Color32, Frame, Margin, TextEdit}; use egui::{RichText, ScrollArea, Ui}; -use std::collections::HashSet; use std::sync::{Arc, RwLock}; #[derive(PartialEq)] @@ -83,12 +83,6 @@ impl UpdateDataContractScreen { }) .or_else(|| qualified_identities.first().cloned()); - let selected_wallet = if let Some(ref identity) = selected_qualified_identity { - get_selected_wallet(identity, Some(app_context), None).unwrap_or(None) - } else { - None - }; - let excluded_aliases = ["dpns", "keyword_search", "token_history", "withdrawals"]; let contracts = app_context.get_contracts().unwrap_or_else(|error| { MessageBanner::set_global( @@ -109,16 +103,18 @@ impl UpdateDataContractScreen { let selected_key = selected_qualified_identity.as_ref().and_then(|identity| { identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::CRITICAL], + SigningScope::NonBatch, + )) .cloned() }); + let selected_wallet = selected_qualified_identity + .as_ref() + .and_then(|identity| wallet_for_signing_key(identity, selected_key.as_ref())); + let selected_identity_string = selected_qualified_identity .as_ref() .map(|qi| qi.identity.id().to_string(Encoding::Base58)) @@ -493,20 +489,15 @@ impl ScreenLike for UpdateDataContractScreen { if let Some(identity) = &self.selected_qualified_identity { // Auto-select a suitable key for contract updates self.selected_key = identity - .identity - .get_first_public_key_matching( + .signing_key_now(KeyRequirements::new( Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + &[SecurityLevel::CRITICAL], + SigningScope::NonBatch, + )) .cloned(); - // Update wallet self.selected_wallet = - get_selected_wallet(identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + wallet_for_signing_key(identity, self.selected_key.as_ref()); self.wallet_open_attempted = false; // Re-parse contract with new owner ID @@ -528,6 +519,7 @@ impl ScreenLike for UpdateDataContractScreen { identity, &mut self.selected_key, TransactionType::UpdateContract, + SigningScope::NonBatch, ); } } @@ -541,6 +533,13 @@ impl ScreenLike for UpdateDataContractScreen { } if self.selected_key.is_none() { + if self.selected_qualified_identity.is_some() { + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new(super::document_action_screen::NO_SIGNING_KEY_NOTICE) + .color(DashColors::warning_color(dark_mode)), + ); + } return AppAction::None; } @@ -664,4 +663,36 @@ mod tests { assert!(screen.known_contracts.is_empty()); } + + /// Regression: an identity holding only a key that cannot sign (here an + /// expired one) passed the screen's gate, and the form stopped with no + /// word once the key choice found nothing. + #[test] + fn an_identity_without_a_usable_key_is_told_so() { + use crate::context::test_support::{auth_key, identity_holding}; + use egui_kittest::kittest::Queryable; + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(dir.path()); + let identity = + identity_holding(&[auth_key(1, SecurityLevel::CRITICAL).with_limits(None, Some(1))]); + let mut screen = UpdateDataContractScreen::new(&ctx); + screen.qualified_identities = vec![identity.clone()]; + screen.selected_qualified_identity = Some(identity); + screen.selected_key = None; + + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(1280.0, 900.0)) + .build_ui_state( + |ui, screen: &mut UpdateDataContractScreen| { + screen.ui(ui); + }, + screen, + ); + harness.run(); + assert!( + harness + .query_by_label(super::super::document_action_screen::NO_SIGNING_KEY_NOTICE) + .is_some() + ); + } } diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index ec75b1a14..daa738c6c 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -5,9 +5,9 @@ use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::dashpay::validate_account_label; +use crate::model::identity_key_usability::SigningScope; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; -use crate::ui::components::ResultBannerExt; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::info_popup::InfoPopup; @@ -19,10 +19,11 @@ use crate::ui::components::wallet_unlock_popup::{ }; use crate::ui::dashpay::DashPaySubscreen; use crate::ui::helpers::{TransactionType, add_key_chooser}; -use crate::ui::identity::get_selected_wallet; use crate::ui::identity::keys::add_key_screen::AddKeyScreen; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::theme::{DashColors, Typography}; use crate::ui::{MessageType, RootScreenType, Screen, ScreenLike}; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::platform::IdentityPublicKey; use egui::{RichText, ScrollArea, TextEdit, Ui}; @@ -308,29 +309,17 @@ impl ScreenLike for AddContactScreen { if should_auto_select { if let Some(identity) = &self.selected_identity { - // Auto-select a suitable AUTHENTICATION key for signing contact requests - // Platform requires CRITICAL or HIGH security level for contact request signing - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; - use std::collections::HashSet; self.selected_key = identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL, SecurityLevel::HIGH]), - KeyType::all_key_types().into(), - false, + .dashpay_signing_key( + &self.app_context.dashpay_contract(), + "contactRequest", ) .cloned(); - // Update wallet if not already set - if self.selected_wallet.is_none() { - self.selected_wallet = - get_selected_wallet(identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); - self.wallet_open_attempted = false; - } + // The wallet holding the key that signs the request. + self.selected_wallet = + wallet_for_signing_key(identity, self.selected_key.as_ref()); + self.wallet_open_attempted = false; } else { self.selected_key = None; self.selected_wallet = None; @@ -348,6 +337,10 @@ impl ScreenLike for AddContactScreen { identity, &mut self.selected_key, TransactionType::ContactRequest, + SigningScope::Document { + contract_id: self.app_context.dashpay_contract().id(), + document_type_name: "contactRequest", + }, ); if !matches!(key_action, AppAction::None) { inner_action = key_action; diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index eaeb72e54..f2f80f0bf 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -7,14 +7,14 @@ use crate::context::AppContext; use crate::model::dashpay::{UnreadableContactInfoPolicy, contact_request_recipient}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{MessageBanner, ResultBannerExt}; -use crate::ui::identity::get_selected_wallet; +use crate::ui::dashpay::dashpay_action_wallet; use crate::ui::identity::keys::add_key_screen::AddKeyScreen; use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{MessageType, Screen, ScreenLike, ScreenType}; @@ -116,9 +116,8 @@ impl ContactRequests { .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Get wallet for the selected identity - new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + new_self.selected_wallet = + dashpay_action_wallet(&preferred, &app_context, "contactRequest"); } new_self @@ -141,9 +140,8 @@ impl ContactRequests { .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); // Update wallet for the newly selected identity - self.selected_wallet = get_selected_wallet(id, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + self.selected_wallet = + dashpay_action_wallet(id, &self.app_context, "contactRequest"); self.wallet_open_attempted = false; } else { self.selected_identity_string.clear(); @@ -462,10 +460,11 @@ impl ContactRequests { // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { - self.selected_wallet = - get_selected_wallet(identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + self.selected_wallet = dashpay_action_wallet( + identity, + &self.app_context, + "contactRequest", + ); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/mod.rs b/src/ui/dashpay/mod.rs index 88d0b1cbc..ea6d87744 100644 --- a/src/ui/dashpay/mod.rs +++ b/src/ui/dashpay/mod.rs @@ -24,6 +24,18 @@ use chrono::{LocalResult, TimeZone, Utc}; use chrono_humanize::HumanTime; use dash_sdk::platform::Identifier; +/// The wallet holding the key `identity` signs a `document_type_name` DashPay +/// document with, for the unlock prompt. `None` when no key qualifies or no +/// wallet derives it. +pub(crate) fn dashpay_action_wallet( + identity: &crate::model::qualified_identity::QualifiedIdentity, + app_context: &AppContext, + document_type_name: &str, +) -> Option>> { + let key = identity.dashpay_signing_key(&app_context.dashpay_contract(), document_type_name); + crate::ui::identity::wallet_for_signing_key(identity, key) +} + /// Persist a contact's DET-local private memo (nickname / notes / hidden flag) /// to the WalletBackend k/v sidecar — the single save path shared by the /// contact list, contact details, and profile-viewer inline editors. @@ -141,3 +153,26 @@ pub fn render_no_identities_card(ui: &mut Ui, app_context: &Arc) -> }) .inner } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression: the unlock prompt looked for a DPNS signing key, so an + /// identity whose only key is bound to DashPay got no wallet to unlock. + #[test] + fn the_unlock_wallet_follows_a_key_bound_to_dashpay() { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().expect("tempdir"); + let app_context = crate::context::test_support::test_app_context(dir.path()); + let (identity, wallet) = crate::context::test_support::identity_with_bound_wallet_key( + ContractBounds::SingleContract { + id: app_context.dashpay_contract().id(), + }, + ); + + let resolved = dashpay_action_wallet(&identity, &app_context, "contactRequest"); + assert!(resolved.is_some_and(|resolved| std::sync::Arc::ptr_eq(&resolved, &wallet))); + } +} diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index 5b3cff47d..d503327f3 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -7,6 +7,7 @@ use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::MessageType; +use crate::ui::components::MessageBanner; use crate::ui::components::avatar::Avatar; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -15,9 +16,8 @@ use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{MessageBanner, ResultBannerExt}; +use crate::ui::dashpay::dashpay_action_wallet; use crate::ui::helpers::{ModalOpeningGuard, clicked_outside_window_after_open}; -use crate::ui::identity::get_selected_wallet; use crate::ui::state::{AvatarCache, PendingRequest}; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -138,9 +138,7 @@ impl ProfileScreen { new_self.selected_identity_string ); - new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + new_self.selected_wallet = dashpay_action_wallet(&preferred, &app_context, "profile"); } new_self @@ -404,9 +402,7 @@ impl ProfileScreen { // Update wallet for the newly selected identity if let Some(identity) = &self.selected_identity { self.selected_wallet = - get_selected_wallet(identity, Some(&self.app_context), None) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); + dashpay_action_wallet(identity, &self.app_context, "profile"); } else { self.selected_wallet = None; } diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 2f2a22ede..56317d59e 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -1,9 +1,11 @@ use crate::app::AppAction; use crate::backend_task::dashpay::DashPayTask; -use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; +use crate::backend_task::error::TaskError; +use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::info_popup::InfoPopup; @@ -14,38 +16,39 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; -use crate::ui::identity::get_selected_wallet; +use crate::ui::state::PendingRequest; use crate::ui::theme::DashColors; use crate::ui::{MessageType, RootScreenType, ScreenLike}; -use egui::{RichText, ScrollArea, TextEdit, Ui}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use egui::{RichText, ScrollArea, Ui}; use std::sync::{Arc, RwLock}; const QR_CODE_INFO_TEXT: &str = "About Contact QR Codes:\n\n\ - QR codes allow instant mutual contact establishment.\n\n\ - The recipient can scan to automatically send and accept contact requests.\n\n\ - QR codes expire after the specified validity period.\n\n\ - Each QR code is unique and can only be used once.\n\n\ - WARNING: Anyone with this QR code can automatically become your contact."; - -const ACCOUNT_INDEX_INFO_TEXT: &str = "Account Index:\n\n\ - The account index determines which HD wallet account is used for this contact relationship.\n\n\ - Most users should leave this at 0 (the default).\n\n\ - Advanced users may use different account indices to segregate contacts \ - (e.g., separate personal and business contacts into different wallet accounts).\n\n\ - The account index is used in the derivation path: m/9'/5'/15'/account'/..."; + Someone who scans your QR code sends you a contact request that DashPay wallets can accept automatically.\n\n\ + The QR code shows your username and works for one hour.\n\n\ + WARNING: Anyone who has this QR code before it expires can send you such a request. Share it only with people you want as contacts."; + +/// The wallet whose recovery phrase derives `identity`'s contact QR code key, +/// for the unlock prompt. +fn qr_wallet( + app_context: &AppContext, + identity: &QualifiedIdentity, +) -> Option>> { + let seed_hash = app_context.contact_qr_wallet_seed_hash(identity).ok()?; + identity.associated_wallets.get(&seed_hash).cloned() +} pub struct QRCodeGeneratorScreen { pub app_context: Arc, pub selected_identity: Option, selected_identity_string: String, - account_index: String, - validity_hours: String, generated_qr_data: Option, + /// The outstanding generate request and the identity it is for. Only its + /// result is shown, and only while that identity is still selected. + qr_request: PendingRequest, qr_code: Option, show_info_popup: bool, - show_advanced_options: bool, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, wallet_open_attempted: bool, @@ -57,12 +60,10 @@ impl QRCodeGeneratorScreen { app_context: app_context.clone(), selected_identity: None, selected_identity_string: String::new(), - account_index: "0".to_string(), - validity_hours: "24".to_string(), generated_qr_data: None, + qr_request: PendingRequest::default(), qr_code: None, show_info_popup: false, - show_advanced_options: false, selected_wallet: None, wallet_unlock_popup: WalletUnlockPopup::new(), wallet_open_attempted: false, @@ -72,7 +73,6 @@ impl QRCodeGeneratorScreen { if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; let selected_id = app_context.selected_identity_id(); @@ -84,14 +84,24 @@ impl QRCodeGeneratorScreen { new_self.selected_identity_string = preferred.identity.id().to_string(Encoding::Base58); // Get wallet for the selected identity - new_self.selected_wallet = get_selected_wallet(&preferred, Some(&app_context), None) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + new_self.selected_wallet = qr_wallet(&new_self.app_context, &preferred); } new_self } + /// React to a new `selected_identity`: follow its wallet and drop the QR + /// code shown for the previous one. + fn identity_changed(&mut self) { + self.selected_wallet = self + .selected_identity + .as_ref() + .and_then(|identity| qr_wallet(&self.app_context, identity)); + self.wallet_open_attempted = false; + self.generated_qr_data = None; + self.qr_request.clear(); + } + /// Dispatch the auto-accept QR build to the backend, which resolves the key /// and derives the proof through the JIT chokepoint (no seed in the UI). fn generate_qr_code(&mut self) -> AppAction { @@ -104,37 +114,13 @@ impl QRCodeGeneratorScreen { return AppAction::None; }; - let account_idx = match self.account_index.parse::() { - Ok(v) => v, - Err(_) => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Invalid account index number", - MessageType::Error, - ); - return AppAction::None; - } - }; - - let validity = match self.validity_hours.parse::() { - Ok(v) if v > 0 && v <= 720 => v, // Max 30 days - _ => { - MessageBanner::set_global( - self.app_context.egui_ctx(), - "Validity hours must be between 1 and 720", - MessageType::Error, - ); - return AppAction::None; - } - }; - - AppAction::BackendTask(BackendTask::DashPayTask(Box::new( - DashPayTask::GenerateAutoAcceptQrCode { + let identity_id = identity.identity.id(); + self.qr_request.dispatch_with( + BackendTask::DashPayTask(Box::new(DashPayTask::GenerateAutoAcceptQrCode { identity: identity.clone(), - account_reference: account_idx, - validity_hours: validity, - }, - ))) + })), + identity_id, + ) } pub fn render(&mut self, ui: &mut Ui) -> AppAction { @@ -151,9 +137,6 @@ impl QRCodeGeneratorScreen { if crate::ui::helpers::info_icon_button(ui, QR_CODE_INFO_TEXT).clicked() { self.show_info_popup = true; } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.checkbox(&mut self.show_advanced_options, "Advanced Options"); - }); }); ui.separator(); @@ -203,67 +186,12 @@ impl QRCodeGeneratorScreen { ); if response.changed() { - // Update wallet for the newly selected identity - if let Some(identity) = &self.selected_identity { - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - ) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); - } else { - self.selected_wallet = None; - } - self.wallet_open_attempted = false; - // Clear generated QR code when identity changes - self.generated_qr_data = None; + self.identity_changed(); } }); ui.end_row(); }); - // Advanced options (only shown when checkbox is checked) - if self.show_advanced_options { - ui.add_space(10.0); - egui::Grid::new("qr_advanced_config_grid") - .num_columns(2) - .spacing([10.0, 10.0]) - .show(ui, |ui| { - ui.horizontal(|ui| { - ui.label( - RichText::new("Account Index:") - .color(DashColors::text_primary(dark_mode)), - ); - crate::ui::helpers::info_icon_button(ui, ACCOUNT_INDEX_INFO_TEXT); - }); - ui.add( - TextEdit::singleline(&mut self.account_index) - .hint_text("0") - .desired_width(100.0), - ); - ui.end_row(); - - ui.label( - RichText::new("Validity (hours):") - .color(DashColors::text_primary(dark_mode)), - ); - ui.horizontal(|ui| { - ui.add( - TextEdit::singleline(&mut self.validity_hours) - .hint_text("24") - .desired_width(100.0), - ); - ui.label( - RichText::new("How long the QR code remains valid (default: 24)") - .small() - .color(DashColors::text_secondary(dark_mode)), - ); - }); - ui.end_row(); - }); - } - ui.add_space(10.0); // Check wallet lock status before showing generate button @@ -442,9 +370,122 @@ impl ScreenLike for QRCodeGeneratorScreen { // Banner display is handled globally by AppState; no side-effects needed. } - fn display_task_result(&mut self, result: BackendTaskSuccessResult) { - if let BackendTaskSuccessResult::DashPayAutoAcceptQrCode(qr_string) = result { + fn display_backend_task_result( + &mut self, + context: &BackendTaskContext, + result: BackendTaskSuccessResult, + ) { + let BackendTaskSuccessResult::DashPayAutoAcceptQrCode(qr_string) = result else { + return; + }; + // A superseded request, or one for an identity no longer selected, + // would show another identity's contact code under this one. + let Some(requested_for) = self.qr_request.settle(context) else { + return; + }; + if self + .selected_identity + .as_ref() + .is_some_and(|identity| identity.identity.id() == requested_for) + { self.generated_qr_data = Some(qr_string); } } + + fn display_backend_task_error(&mut self, context: &BackendTaskContext, _error: &TaskError) { + self.qr_request.settle(context); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend_task::BackendTaskContext; + use crate::context::test_support::bare_user_identity; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::platform::Identifier; + + /// The context the screen's generate request is reported under. + fn dispatch_context(action: AppAction) -> BackendTaskContext { + match action { + AppAction::BackendTaskWithContext { context, .. } => context, + _ => BackendTaskContext::Unknown, + } + } + + fn qr_result(qr: &str) -> BackendTaskSuccessResult { + BackendTaskSuccessResult::DashPayAutoAcceptQrCode(qr.to_string()) + } + + /// Regression: a QR code generated for one identity arrived after the user + /// had picked another and was shown under it, handing out the first + /// identity's contact code as if it were the second's. + #[test] + fn a_qr_code_for_a_previously_selected_identity_is_dropped() { + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let mut screen = QRCodeGeneratorScreen::new(context); + let first = bare_user_identity(Identifier::new([1; 32]), Network::Testnet); + let second = bare_user_identity(Identifier::new([2; 32]), Network::Testnet); + + screen.selected_identity = Some(first); + let for_first = dispatch_context(screen.generate_qr_code()); + screen.selected_identity = Some(second); + screen.identity_changed(); + screen.display_backend_task_result(&for_first, qr_result("first")); + assert_eq!(screen.generated_qr_data, None, "the identity changed"); + + // A newer request for the second identity supersedes the first one. + let for_second = dispatch_context(screen.generate_qr_code()); + screen.display_backend_task_result(&for_first, qr_result("first")); + assert_eq!(screen.generated_qr_data, None, "a superseded request"); + screen.display_backend_task_result(&for_second, qr_result("second")); + assert_eq!(screen.generated_qr_data.as_deref(), Some("second")); + } + + /// Regression: hydration lists every loaded wallet with an identity, and + /// the contact QR code took the lowest-sorting one, so an unrelated + /// wallet could derive the code's key. It now comes from the wallet the + /// identity's record names as its owner, and none without that record. + #[test] + fn the_contact_qr_key_comes_from_the_identitys_own_wallet() { + use crate::model::wallet::test_support::open_wallet_on; + use crate::wallet_backend::DetKv; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + let dir = tempfile::tempdir().unwrap(); + let ctx = crate::context::test_support::test_app_context(dir.path()); + ctx.set_det_kv_override_for_test(DetKv::from_store(Arc::new(InMemoryKv::default()))); + let mut wallets = [ + open_wallet_on(crate::test_support::keys::seed()), + open_wallet_on(crate::test_support::keys::seed()), + ]; + wallets.sort_by_key(|wallet| wallet.seed_hash()); + let [unrelated, owner] = wallets.map(|wallet| (wallet.seed_hash(), wallet)); + + let mut identity = bare_user_identity(Identifier::new([1; 32]), Network::Testnet); + for (hash, wallet) in [unrelated, owner.clone()] { + identity + .associated_wallets + .insert(hash, Arc::new(RwLock::new(wallet))); + } + ctx.insert_local_qualified_identity(&identity, &Some((owner.0, 0))) + .expect("store identity"); + assert_ne!(identity.dashpay_wallet_seed_hash(), Some(owner.0)); + assert_eq!( + ctx.contact_qr_wallet_seed_hash(&identity).ok(), + Some(owner.0) + ); + let unlock = qr_wallet(&ctx, &identity).expect("the owner's wallet"); + assert_eq!(unlock.read().expect("wallet").seed_hash(), owner.0); + + // No record, so no owner on file. + let mut unlinked = identity.clone(); + unlinked.identity.set_id(Identifier::new([2; 32])); + assert!(matches!( + ctx.contact_qr_wallet_seed_hash(&unlinked), + Err(TaskError::ContactQrWalletMissing) + )); + assert!(qr_wallet(&ctx, &unlinked).is_none()); + } } diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 6730a1e3c..d99422e2d 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -1,10 +1,11 @@ use crate::app::AppAction; use crate::backend_task::dashpay::DashPayTask; -use crate::backend_task::dashpay::auto_accept_proof::AutoAcceptProofData; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::dashpay::ContactQr; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; +use crate::ui::components::MessageBanner; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::left_panel::add_left_panel; @@ -13,14 +14,11 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{MessageBanner, ResultBannerExt}; use crate::ui::dashpay::dashpay_screen::DashPaySubscreen; -use crate::ui::identity::get_selected_wallet; +use crate::ui::identity::wallet_for_signing_key; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; use egui::{RichText, ScrollArea, TextEdit, Ui}; -use std::collections::HashSet; use std::sync::{Arc, RwLock}; pub struct QRScannerScreen { @@ -28,7 +26,7 @@ pub struct QRScannerScreen { pub selected_identity: Option, selected_identity_string: String, qr_data_input: String, - parsed_qr_data: Option, + parsed_qr_data: Option, sending: bool, selected_wallet: Option>>, wallet_unlock_popup: WalletUnlockPopup, @@ -56,6 +54,10 @@ impl QRScannerScreen { }) .unwrap_or_default(); + let selected_wallet = selected_identity + .as_ref() + .and_then(|identity| signing_key_wallet(&app_context, identity)); + Self { app_context, selected_identity, @@ -63,7 +65,7 @@ impl QRScannerScreen { qr_data_input: String::new(), parsed_qr_data: None, sending: false, - selected_wallet: None, + selected_wallet, wallet_unlock_popup: WalletUnlockPopup::new(), wallet_open_attempted: false, } @@ -80,7 +82,7 @@ impl QRScannerScreen { return; } - match AutoAcceptProofData::from_qr_string(&self.qr_data_input) { + match ContactQr::parse(&self.qr_data_input, now_secs()) { Ok(data) => { self.parsed_qr_data = Some(data); MessageBanner::set_global( @@ -93,7 +95,7 @@ impl QRScannerScreen { self.parsed_qr_data = None; MessageBanner::set_global( self.app_context.egui_ctx(), - "The QR code is not valid for DashPay. Scan a DashPay code and try again.", + error.to_string(), MessageType::Error, ) .with_details(error); @@ -105,21 +107,14 @@ impl QRScannerScreen { if let Some(identity) = &self.selected_identity { if let Some(qr_data) = &self.parsed_qr_data { // Get signing key - let signing_key = match identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([ - SecurityLevel::CRITICAL, - SecurityLevel::HIGH, - SecurityLevel::MEDIUM, - ]), - HashSet::from([KeyType::ECDSA_SECP256K1]), - false, - ) { + let signing_key = match identity + .dashpay_signing_key(&self.app_context.dashpay_contract(), "contactRequest") + { Some(key) => key, None => { MessageBanner::set_global( self.app_context.egui_ctx(), - "No suitable signing key found. This operation requires a ECDSA_SECP256K1 AUTHENTICATION key.", + "This identity has no key that can send a contact request. Add a high-security authentication key to the identity and try again.", MessageType::Error, ); return AppAction::None; @@ -133,12 +128,7 @@ impl QRScannerScreen { BackendTask::DashPayTask(Box::new(DashPayTask::SendContactRequestWithProof { identity: identity.clone(), signing_key: signing_key.clone(), - to_identity_id: qr_data.identity_id, - account_label: Some(format!( - "QR Contact (Account #{})", - qr_data.account_reference - )), - qr_auto_accept: qr_data.clone(), + qr: qr_data.clone(), })); return AppAction::BackendTask(task); @@ -208,17 +198,10 @@ impl QRScannerScreen { // Update wallet if identity changed let new_identity_id = self.selected_identity.as_ref().map(|i| i.identity.id()); if prev_identity_id != new_identity_id { - if let Some(identity) = &self.selected_identity { - self.selected_wallet = get_selected_wallet( - identity, - Some(&self.app_context), - None, - ) - .or_show_error(self.app_context.egui_ctx()) - .unwrap_or(None); - } else { - self.selected_wallet = None; - } + self.selected_wallet = self + .selected_identity + .as_ref() + .and_then(|identity| signing_key_wallet(&self.app_context, identity)); self.wallet_open_attempted = false; } }); @@ -233,7 +216,7 @@ impl QRScannerScreen { ui.add( TextEdit::multiline(&mut self.qr_data_input) - .hint_text("dash:?di=...") + .hint_text("dash:?du=...&dapk=...") .desired_rows(3) .desired_width(f32::INFINITY) ); @@ -262,19 +245,14 @@ impl QRScannerScreen { .num_columns(2) .spacing([10.0, 5.0]) .show(ui, |ui| { - ui.label("Contact Identity:"); - ui.label(qr_data.identity_id.to_string( - dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58 - )); - ui.end_row(); - - ui.label("Account Reference:"); - ui.label(format!("{}", qr_data.account_reference)); + ui.label("Contact:"); + ui.label(qr_data.dpns_name()); ui.end_row(); ui.label("Expires:"); - let expiry_time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(qr_data.expires_at); - ui.label(format!("{:?}", expiry_time)); + ui.label(crate::model::datetime::local_date_time_from_unix_millis( + u64::from(qr_data.expires_at()) * 1000, + )); ui.end_row(); }); @@ -316,8 +294,7 @@ impl QRScannerScreen { ui.add_space(10.0); - ui.label(RichText::new("ℹ️ This will send a contact request that will be automatically accepted").small()); - ui.label(RichText::new("⚡ Both you and the contact will become mutual contacts instantly").small()); + ui.label(RichText::new("ℹ️ This sends a contact request that your contact's DashPay wallet can accept automatically.").small()); }); } @@ -327,11 +304,9 @@ impl QRScannerScreen { ui.group(|ui| { ui.label(RichText::new("ℹ️ About QR Code Scanning").strong()); ui.separator(); - ui.label("• QR codes enable instant mutual contact establishment"); - ui.label("• The contact request is automatically accepted by both parties"); - ui.label("• No manual approval is needed when using valid QR codes"); - ui.label("• QR codes expire after the specified time period"); - ui.label("• Each QR code can only be used once"); + ui.label("• A contact QR code names your contact's username"); + ui.label("• Your contact's DashPay wallet can accept a request sent from it automatically"); + ui.label("• A contact QR code works for one hour after it is created"); }); }); @@ -403,4 +378,80 @@ impl ScreenLike for QRScannerScreen { fn display_task_result(&mut self, result: BackendTaskSuccessResult) { self.display_task_result(result); } + + fn display_backend_task_error( + &mut self, + _context: &crate::backend_task::BackendTaskContext, + _error: &crate::backend_task::error::TaskError, + ) { + // The banner reports the failure; the form must offer Send again. + self.sending = false; + } +} + +/// The wall clock in Unix seconds, the clock a contact QR code's expiry is +/// compared against. +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs()) + .unwrap_or_default() +} + +/// The wallet holding the key a contact request from `identity` is signed +/// with (the key [`QRScannerScreen::send_contact_request_with_proof`] takes), +/// for the unlock prompt. +fn signing_key_wallet( + app_context: &AppContext, + identity: &QualifiedIdentity, +) -> Option>> { + let key = identity.dashpay_signing_key(&app_context.dashpay_contract(), "contactRequest"); + wallet_for_signing_key(identity, key) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression: the screen preselected an identity but no wallet, and + /// picked the wallet only when the identity changed, so the default path + /// never checked whether the wallet holding the DashPay signing key was + /// locked before offering to send. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_preselected_identity_brings_its_signing_key_wallet() { + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::wired_test_app_context(dir.path()).await; + let (identity, wallet) = crate::context::test_support::identity_with_bound_wallet_key( + ContractBounds::SingleContract { + id: ctx.dashpay_contract().id(), + }, + ); + let seed_hash = wallet.read().expect("wallet").seed_hash(); + ctx.wallet_context().insert_test_wallet(seed_hash, wallet); + ctx.insert_local_qualified_identity(&identity, &None) + .expect("store identity"); + + let screen = QRScannerScreen::new(ctx); + let selected = screen + .selected_wallet + .expect("the wallet holding the DashPay signing key"); + assert_eq!(selected.read().expect("wallet").seed_hash(), seed_hash); + } + + /// Regression: a failed send left the screen spinning with no way to + /// retry, as only a success cleared the sending state. + #[test] + fn a_failed_send_lets_the_user_retry() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut screen = + QRScannerScreen::new(crate::context::test_support::test_app_context(dir.path())); + screen.sending = true; + screen.display_backend_task_error( + &crate::backend_task::BackendTaskContext::Unknown, + &crate::backend_task::error::TaskError::ContactQrExpired, + ); + assert!(!screen.sending); + } } diff --git a/src/ui/helpers.rs b/src/ui/helpers.rs index a7aac0ee0..e806ca3cd 100644 --- a/src/ui/helpers.rs +++ b/src/ui/helpers.rs @@ -1,3 +1,5 @@ +use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::{KeyCaveat, SigningScope, key_caveats, now_ms}; use crate::ui::theme::ResponseExt; use std::sync::Arc; @@ -94,7 +96,7 @@ use dash_sdk::{ group::{Group, accessors::v0::GroupV0Getters}, }, identity::{ - Purpose, SecurityLevel, accessors::IdentityGettersV0, + KeyType, Purpose, SecurityLevel, TimestampMillis, accessors::IdentityGettersV0, identity_public_key::accessors::v0::IdentityPublicKeyGettersV0, }, platform_value::string_encoding::Encoding, @@ -148,18 +150,10 @@ pub fn compute_allowed_security_levels( document_type: Option<&DocumentType>, ) -> Vec { match (transaction_type, document_type) { - (TransactionType::DocumentAction, Some(doc_type)) => { - let required_level = doc_type.security_level_requirement(); - let allowed_range = SecurityLevel::CRITICAL as u8..=required_level as u8; - [ - SecurityLevel::CRITICAL, - SecurityLevel::HIGH, - SecurityLevel::MEDIUM, - ] - .into_iter() - .filter(|level| allowed_range.contains(&(*level as u8))) - .collect() - } + ( + TransactionType::DocumentAction | TransactionType::UsernameRegistration, + Some(doc_type), + ) => crate::model::identity_key_usability::document_security_levels(doc_type.as_ref()), _ => transaction_type.allowed_security_levels(), } } @@ -340,6 +334,8 @@ pub enum TransactionType { TokenClaim, /// DashPay contact request - requires Authentication keys for signing (ENCRYPTION key for ECDH is auto-selected) ContactRequest, + /// DPNS username registration - requires ECDSA Authentication keys; security level depends on the document type + UsernameRegistration, } impl TransactionType { @@ -357,7 +353,33 @@ impl TransactionType { TransactionType::TokenTransfer | TransactionType::TokenClaim => { vec![Purpose::TRANSFER, Purpose::AUTHENTICATION] } - TransactionType::ContactRequest => vec![Purpose::AUTHENTICATION], + TransactionType::ContactRequest | TransactionType::UsernameRegistration => { + vec![Purpose::AUTHENTICATION] + } + } + } + + /// The key types this transaction type is signed with, or `None` when + /// every key type is accepted: the ones its automatic key choice takes. + /// Never a type Platform refuses. + pub fn allowed_key_types(&self) -> Option<&'static [KeyType]> { + match self { + TransactionType::ContactRequest => { + Some(crate::model::qualified_identity::DASHPAY_SIGNING_KEY_TYPES) + } + TransactionType::UsernameRegistration => { + Some(crate::model::dpns_usernames::DPNS_SIGNING_KEY_TYPES) + } + TransactionType::TokenTransfer + | TransactionType::TokenClaim + | TransactionType::RegisterContract + | TransactionType::UpdateContract + | TransactionType::Transfer + | TransactionType::Withdraw + | TransactionType::DocumentAction + | TransactionType::TokenAction => { + Some(crate::model::identity_key_usability::PLATFORM_SIGNING_KEY_TYPES) + } } } @@ -376,7 +398,9 @@ impl TransactionType { TransactionType::TokenAction | TransactionType::TokenTransfer | TransactionType::TokenClaim => vec![SecurityLevel::CRITICAL], - TransactionType::ContactRequest => vec![SecurityLevel::CRITICAL, SecurityLevel::HIGH], + TransactionType::ContactRequest | TransactionType::UsernameRegistration => { + vec![SecurityLevel::CRITICAL, SecurityLevel::HIGH] + } } } @@ -392,41 +416,109 @@ impl TransactionType { TransactionType::TokenTransfer => "Token Transfer", TransactionType::TokenClaim => "Token Claim", TransactionType::ContactRequest => "Contact Request", + TransactionType::UsernameRegistration => "Username Registration", } } } -/// Key chooser that filters keys based on transaction type and dev mode. -/// Use this when you already have a specific identity and just need to select a key. -/// Whether `identity` has (a) a loaded private key eligible for the transaction and -/// (b) an eligible public key whose private key is not yet loaded. +/// What a key chooser offers for one transaction: the purposes, security +/// levels and key types the transaction accepts, and the scope its key signs +/// in. The "no eligible key" gate and the list entries both judge keys by it, +/// so the gate never opens onto an empty list. +#[derive(Debug, Clone, Copy)] +struct KeyFilter<'a> { + purposes: &'a [Purpose], + security_levels: &'a [SecurityLevel], + /// `None` accepts every key type. + key_types: Option<&'a [KeyType]>, + scope: SigningScope<'a>, +} + +impl<'a> KeyFilter<'a> { + /// The filter for `transaction_type` within `scope`, at + /// `security_levels` (see [`compute_allowed_security_levels`]). + fn new( + transaction_type: TransactionType, + purposes: &'a [Purpose], + security_levels: &'a [SecurityLevel], + scope: SigningScope<'a>, + ) -> Self { + Self { + purposes, + security_levels, + key_types: transaction_type.allowed_key_types(), + scope, + } + } + + /// Whether `key` has a purpose, security level and key type the + /// transaction accepts. Developer mode lists keys failing this, tagged. + fn accepts(&self, key: &IdentityPublicKey) -> bool { + self.purposes.contains(&key.purpose()) + && self.security_levels.contains(&key.security_level()) + && self + .key_types + .is_none_or(|types| types.contains(&key.key_type())) + } + + /// Whether `key` is ever listed: Platform refuses a disabled key, and a + /// key bound to another contract or document type, so no mode offers + /// them. + fn may_sign(&self, key: &IdentityPublicKey, now: TimestampMillis) -> bool { + !key.is_disabled() && offered_for_manual_choice(&key_caveats(key, self.scope, now)) + } + + /// Whether `key` is listed outside developer mode. + fn offers(&self, key: &IdentityPublicKey, now: TimestampMillis) -> bool { + self.accepts(key) && self.may_sign(key, now) + } +} + +/// The keys `identity`'s chooser lists under `filter`, each as the identity +/// holds it now: filed keys whose private half this device can obtain and +/// that Platform may accept here. Outside developer mode also only keys of a +/// purpose, security level and key type the transaction accepts. +fn listed_keys<'a>( + identity: &'a QualifiedIdentity, + filter: KeyFilter<'a>, + is_dev_mode: bool, + now: TimestampMillis, +) -> impl Iterator + 'a { + identity + .private_keys + .identity_public_keys() + .into_iter() + // The stored copy may predate a limits raise: judge, label and hand + // on the key as the identity holds it now, skipping one it no longer + // holds. + .filter_map(|key_ref| identity.current_public_key(&key_ref.1.identity_public_key)) + // Platform rejects signing with a disabled key or a key bound + // elsewhere, and no signer here can produce a private half this + // device cannot obtain, so never offer one — not even in dev mode, + // where the override only relaxes purpose, security-level and key + // type filters, not signability. + .filter(move |key| filter.may_sign(key, now) && identity.holds_obtainable_key(key)) + .filter(move |key| is_dev_mode || filter.accepts(key)) +} + +/// Whether `identity` has (a) a key the chooser lists outside developer mode +/// and (b) a public key `filter` offers whose private half this device cannot +/// obtain: not loaded, kept in the legacy encrypted form, or derived from a +/// wallet that is not here. Keys are judged as the identity holds them now, +/// as the list judges them. fn key_eligibility( identity: &QualifiedIdentity, - allowed_purposes: &[Purpose], - allowed_security_levels: &[SecurityLevel], + filter: &KeyFilter<'_>, + now: TimestampMillis, ) -> (bool, bool) { let has_suitable_keys_with_private = - identity - .private_keys - .identity_public_keys() - .iter() - .any(|key_ref| { - let key = &key_ref.1.identity_public_key; - allowed_purposes.contains(&key.purpose()) - && allowed_security_levels.contains(&key.security_level()) - }); + listed_keys(identity, *filter, false, now).next().is_some(); - let has_eligible_public_keys_without_private = - identity.identity.public_keys().iter().any(|(_, pub_key)| { - let basic_ok = allowed_purposes.contains(&pub_key.purpose()) - && allowed_security_levels.contains(&pub_key.security_level()); - let has_private = identity - .private_keys - .identity_public_keys() - .iter() - .any(|key_ref| key_ref.1.identity_public_key.id() == pub_key.id()); - basic_ok && !has_private - }); + let has_eligible_public_keys_without_private = identity + .identity + .public_keys() + .values() + .any(|pub_key| filter.offers(pub_key, now) && !identity.holds_obtainable_key(pub_key)); ( has_suitable_keys_with_private, @@ -471,21 +563,21 @@ fn render_no_eligible_key_group( action } -/// Renders a ComboBox listing `identity`'s loaded private keys eligible for the transaction. +/// Renders a ComboBox listing `identity`'s loaded private keys `filter` offers. /// /// With `identity` `None`, prompts the user to pick an identity first. Developer mode also -/// lists otherwise-ineligible keys, tagged via [`format_key_label_dev`]. -#[allow(clippy::too_many_arguments)] +/// lists keys of another purpose, security level or key type, tagged via +/// [`format_key_label_dev`]. fn render_key_combo( ui: &mut Ui, combo_id: &str, width: f32, identity: Option<&QualifiedIdentity>, selected_key: &mut Option, - allowed_purposes: &[Purpose], - allowed_security_levels: &[SecurityLevel], + filter: &KeyFilter<'_>, is_dev_mode: bool, ) { + let now = now_ms(); ComboBox::from_id_salt(combo_id) .width(width) .selected_text( @@ -499,31 +591,17 @@ fn render_key_combo( kui.label("Pick an identity first"); return; }; - for key_ref in qi.private_keys.identity_public_keys() { - let key = &key_ref.1.identity_public_key; - - // Platform rejects signing with a disabled key, so never offer one - // — not even in dev mode, where the override only relaxes purpose and - // security-level filters, not signability. - if key.is_disabled() { - continue; - } - - let is_allowed = is_dev_mode - || (allowed_purposes.contains(&key.purpose()) - && allowed_security_levels.contains(&key.security_level())); - if !is_allowed { - continue; - } - - let is_dev_override = is_dev_mode - && (!allowed_purposes.contains(&key.purpose()) - || !allowed_security_levels.contains(&key.security_level())); + for key in listed_keys(qi, *filter, is_dev_mode, now) { + let is_dev_override = !filter.accepts(key); let label = if is_dev_override { format_key_label_dev(key) } else { format_key_label(key) }; + // A key Platform may refuse stays selectable (the user may know + // better, e.g. a contract group that covers this contract), but + // its entry says why it may not work. + let label = key_entry_label(&label, key_caveats(key, filter.scope, now).first()); if kui .selectable_label(selected_key.as_ref() == Some(key), label) @@ -533,6 +611,77 @@ fn render_key_combo( } } }); + + if let (Some(qi), Some(selected)) = (identity, selected_key.as_ref()) { + let live = qi.live_public_key(selected); + if live != selected { + *selected_key = Some(live.clone()); + } + } + if let Some(key) = selected_key.as_ref() { + render_key_caveats(ui, &key_caveats(key, filter.scope, now)); + } +} + +/// Whether a key with `caveats` is offered for the user to pick: every key +/// but one whose bounds tie it to another contract or document type, which +/// Platform is certain to refuse here. Expired and limited keys stay on +/// offer with their warning; the user may know better. +fn offered_for_manual_choice(caveats: &[KeyCaveat]) -> bool { + !caveats + .iter() + .any(|caveat| matches!(caveat, KeyCaveat::OutOfBounds)) +} + +/// A key list entry for the key labelled `label`, naming its most severe +/// caveat. Each entry is one complete template, so it translates as a unit. +fn key_entry_label(label: &str, caveat: Option<&KeyCaveat>) -> String { + match caveat { + Some(KeyCaveat::Expired { .. }) => format!("{label} (expired)"), + Some(KeyCaveat::ContractGroupBound { .. }) => format!("{label} (contract group)"), + Some(KeyCaveat::Budgeted { .. }) => format!("{label} (spending limit)"), + Some(KeyCaveat::Expiring { .. }) => format!("{label} (expires)"), + // Disabled keys and keys bound to another contract or document type + // are never listed, so no entry names them. + Some(KeyCaveat::Disabled | KeyCaveat::OutOfBounds) | None => label.to_string(), + } +} + +/// The user-facing explanation of a key caveat: what it means and what to do. +pub(crate) fn key_caveat_message(caveat: &KeyCaveat) -> String { + match caveat { + KeyCaveat::Disabled => "This key is disabled, so the network will reject anything signed with it. Choose another key.".to_string(), + KeyCaveat::Expired { expired_at } => format!( + "This key expired on {date}, so the network will reject anything signed with it. Choose another key.", + date = crate::model::datetime::local_date_time_from_unix_millis(*expired_at) + ), + KeyCaveat::OutOfBounds => "This key can only sign for a different contract or document type, so the network will reject this action. Choose another key.".to_string(), + KeyCaveat::ContractGroupBound { group_id } => format!( + "This key can only sign for the contracts in contract group {group_id}. If this contract is not in that group, the network rejects the action. Choose another key if you are not sure.", + group_id = group_id.to_string(Encoding::Base58) + ), + KeyCaveat::Budgeted { total_budget } => format!( + "This key has a lifetime spending limit of {amount}. Once the limit is used up, the network rejects actions signed with it.", + amount = format_credits_as_dash(*total_budget) + ), + KeyCaveat::Expiring { expires_at } => format!( + "This key stops working on {date}.", + date = crate::model::datetime::local_date_time_from_unix_millis(*expires_at) + ), + } +} + +/// Renders the caveats of the selected signing key under a key chooser. +fn render_key_caveats(ui: &mut Ui, caveats: &[KeyCaveat]) { + let dark_mode = ui.style().visuals.dark_mode; + for caveat in caveats { + let color = if caveat.blocks_signing() { + DashColors::error_color(dark_mode) + } else { + DashColors::warning_color(dark_mode) + }; + ui.label(egui::RichText::new(key_caveat_message(caveat)).color(color)); + } } /// Renders a centered title + description block (used above success-screen buttons). @@ -560,12 +709,15 @@ fn render_info_section(ui: &mut Ui, title: &str, description: &str) { ); } +/// `scope` is what the chosen key will sign, for the contract-bounds warning +/// under the chooser. pub fn add_key_chooser( ui: &mut Ui, app_context: &Arc, identity: &QualifiedIdentity, selected_key: &mut Option, transaction_type: TransactionType, + scope: SigningScope<'_>, ) -> AppAction { add_key_chooser_with_doc_type( ui, @@ -574,6 +726,7 @@ pub fn add_key_chooser( selected_key, transaction_type, None, + scope, ) } @@ -586,15 +739,46 @@ pub fn add_key_chooser_with_doc_type( selected_key: &mut Option, transaction_type: TransactionType, document_type: Option<&DocumentType>, + scope: SigningScope<'_>, +) -> AppAction { + let allowed_security_levels = compute_allowed_security_levels(transaction_type, document_type); + add_key_chooser_with_security_levels( + ui, + app_context, + identity, + selected_key, + transaction_type, + &allowed_security_levels, + scope, + ) +} + +/// Key chooser offering keys at `allowed_security_levels` only, for a +/// transaction whose accepted levels no single document type decides (a +/// username registration signs both DPNS preorder and domain documents). +/// Otherwise as [`add_key_chooser_with_doc_type`]. +pub fn add_key_chooser_with_security_levels( + ui: &mut Ui, + app_context: &Arc, + identity: &QualifiedIdentity, + selected_key: &mut Option, + transaction_type: TransactionType, + allowed_security_levels: &[SecurityLevel], + scope: SigningScope<'_>, ) -> AppAction { let is_dev_mode = app_context.user_role().at_least(UserRole::Developer); let mut action = AppAction::None; let allowed_purposes = transaction_type.allowed_purposes(); - let allowed_security_levels = compute_allowed_security_levels(transaction_type, document_type); + let filter = KeyFilter::new( + transaction_type, + &allowed_purposes, + allowed_security_levels, + scope, + ); let (has_suitable_keys_with_private, has_eligible_public_keys_without_private) = - key_eligibility(identity, &allowed_purposes, &allowed_security_levels); + key_eligibility(identity, &filter, now_ms()); if !is_dev_mode && !has_suitable_keys_with_private { action = render_no_eligible_key_group( @@ -616,8 +800,7 @@ pub fn add_key_chooser_with_doc_type( 300.0, Some(identity), selected_key, - &allowed_purposes, - &allowed_security_levels, + &filter, is_dev_mode, ); }); @@ -634,6 +817,7 @@ pub fn add_identity_key_chooser<'a, T>( selected_identity: &mut Option, selected_key: &mut Option, transaction_type: TransactionType, + scope: SigningScope<'_>, ) -> AppAction where T: Iterator, @@ -646,10 +830,12 @@ where selected_key, transaction_type, None, + scope, ) } /// Identity key chooser that filters keys based on transaction type, document type and dev mode +#[allow(clippy::too_many_arguments)] pub fn add_identity_key_chooser_with_doc_type<'a, T>( ui: &mut Ui, app_context: &Arc, @@ -658,6 +844,7 @@ pub fn add_identity_key_chooser_with_doc_type<'a, T>( selected_key: &mut Option, transaction_type: TransactionType, document_type: Option<&DocumentType>, + scope: SigningScope<'_>, ) -> AppAction where T: Iterator, @@ -702,13 +889,19 @@ where let allowed_purposes = transaction_type.allowed_purposes(); let allowed_security_levels = compute_allowed_security_levels(transaction_type, document_type); + let filter = KeyFilter::new( + transaction_type, + &allowed_purposes, + &allowed_security_levels, + scope, + ); // Show the "no eligible key" notice instead of the combo when the selected // identity has no loaded eligible key (outside developer mode). let mut show_combo = true; if let Some(qi) = selected_identity.as_ref() { let (has_suitable_keys_with_private, has_eligible_public_keys_without_private) = - key_eligibility(qi, &allowed_purposes, &allowed_security_levels); + key_eligibility(qi, &filter, now_ms()); if !is_dev_mode && !has_suitable_keys_with_private { show_combo = false; action = render_no_eligible_key_group( @@ -728,8 +921,7 @@ where 220.0, selected_identity.as_ref(), selected_key, - &allowed_purposes, - &allowed_security_levels, + &filter, is_dev_mode, ); } @@ -1122,8 +1314,256 @@ pub fn show_group_token_success_screen_with_fee( #[cfg(test)] mod tests { + /// Regression: a key bound to another contract or document type was + /// offered, though its bounds prove Platform refuses it here. Expired and + /// limited keys stay on offer (IDN-024), with their warning. + #[test] + fn the_manual_chooser_leaves_out_keys_bound_elsewhere() { + use super::{KeyCaveat, offered_for_manual_choice}; + assert!(!offered_for_manual_choice(&[KeyCaveat::OutOfBounds])); + assert!(!offered_for_manual_choice(&[ + KeyCaveat::Expired { expired_at: 1 }, + KeyCaveat::OutOfBounds, + ])); + for kept in [ + KeyCaveat::Expired { expired_at: 1 }, + KeyCaveat::Budgeted { total_budget: 5 }, + KeyCaveat::Expiring { expires_at: 9 }, + KeyCaveat::ContractGroupBound { + group_id: dash_sdk::platform::Identifier::new([3; 32]), + }, + ] { + assert!( + offered_for_manual_choice(std::slice::from_ref(&kept)), + "{kept:?}" + ); + } + assert!(offered_for_manual_choice(&[])); + } + use super::*; + /// The filter the contact-request chooser applies, within DashPay's + /// contactRequest document type. + fn contact_request_filter<'a>( + purposes: &'a [Purpose], + levels: &'a [SecurityLevel], + dashpay: Identifier, + ) -> KeyFilter<'a> { + KeyFilter { + purposes, + security_levels: levels, + key_types: TransactionType::ContactRequest.allowed_key_types(), + scope: SigningScope::Document { + contract_id: dashpay, + document_type_name: "contactRequest", + }, + } + } + + /// Regression: the chooser gate counted any filed key of the right purpose + /// and level, while the list left out disabled keys and keys bound + /// elsewhere, so an identity holding only such a key got an empty list + /// instead of the no-usable-key guidance. Both now apply one predicate, + /// to the key as the identity holds it now. + #[test] + fn the_chooser_gate_counts_only_keys_the_list_offers() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use dash_sdk::dpp::identity::KeyType; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + + let dashpay = Identifier::new([0xDA; 32]); + let purposes = TransactionType::ContactRequest.allowed_purposes(); + let levels = TransactionType::ContactRequest.allowed_security_levels(); + let filter = contact_request_filter(&purposes, &levels, dashpay); + let high = |id| auth_key(id, SecurityLevel::HIGH); + let now = 1_000; + + let usable = identity_holding(&[high(1)]); + assert_eq!(key_eligibility(&usable, &filter, now), (true, false)); + + let elsewhere = identity_holding(&[bound_to( + high(1), + ContractBounds::SingleContract { + id: Identifier::new([9; 32]), + }, + )]); + assert_eq!(key_eligibility(&elsewhere, &filter, now), (false, false)); + + let mut bls = high(1); + bls.set_key_type(KeyType::BLS12_381); + assert_eq!( + key_eligibility(&identity_holding(&[bls]), &filter, now), + (false, false), + "a DashPay document is signed with an ECDSA key" + ); + + // Filed while enabled, disabled on the network since. + let mut disabled_live = identity_holding(&[high(1)]); + let mut disabled = high(1); + disabled.set_disabled_at(1); + disabled_live + .identity + .set_public_keys(std::collections::BTreeMap::from([(1, disabled)])); + assert_eq!( + key_eligibility(&disabled_live, &filter, now), + (false, false) + ); + + // Published only: guidance to load it, unless it could not sign anyway. + let mut public_only = identity_holding(&[]); + public_only + .identity + .set_public_keys(std::collections::BTreeMap::from([(1, high(1))])); + assert_eq!(key_eligibility(&public_only, &filter, now), (false, true)); + public_only + .identity + .set_public_keys(std::collections::BTreeMap::from([( + 1, + bound_to( + high(1), + ContractBounds::SingleContract { + id: Identifier::new([9; 32]), + }, + ), + )])); + assert_eq!(key_eligibility(&public_only, &filter, now), (false, false)); + } + + /// Regression: the chooser gate and its list counted a filed key whose + /// private half this device cannot obtain (kept in the legacy encrypted + /// form, or as a reference into a wallet not on this device), so the form + /// opened onto a key that cannot sign. Such a key is offered nowhere, and + /// the gate gives the guidance for a key whose private half is missing. + #[test] + fn the_chooser_gate_and_list_skip_keys_this_device_cannot_obtain() { + use crate::context::test_support::{ + auth_key, hold_as_legacy_encrypted, hold_as_wallet_reference, identity_holding, + }; + + let dashpay = Identifier::new([0xDA; 32]); + let purposes = TransactionType::ContactRequest.allowed_purposes(); + let levels = TransactionType::ContactRequest.allowed_security_levels(); + let filter = contact_request_filter(&purposes, &levels, dashpay); + let now = 1_000; + + let mut legacy = identity_holding(&[auth_key(1, SecurityLevel::HIGH)]); + hold_as_legacy_encrypted(&mut legacy, 1); + let mut walletless = identity_holding(&[auth_key(1, SecurityLevel::HIGH)]); + hold_as_wallet_reference(&mut walletless, 1, [0x5A; 32]); + + for (case, identity) in [("legacy encrypted", legacy), ("wallet missing", walletless)] { + assert_eq!( + key_eligibility(&identity, &filter, now), + (false, true), + "{case}" + ); + assert_eq!( + listed_keys(&identity, filter, true, now).count(), + 0, + "{case}: not listed, not even in developer mode" + ); + } + } + + /// Regression: a filed key the identity no longer holds, removed or + /// replaced under the same id, was listed by its stored copy, though + /// Platform verifies against the identity's current key. + #[test] + fn the_chooser_lists_no_key_the_identity_no_longer_holds() { + use crate::context::test_support::{auth_key, identity_holding}; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let dashpay = Identifier::new([0xDA; 32]); + let purposes = TransactionType::ContactRequest.allowed_purposes(); + let levels = TransactionType::ContactRequest.allowed_security_levels(); + let filter = contact_request_filter(&purposes, &levels, dashpay); + let now = 1_000; + + let mut removed = identity_holding(&[auth_key(1, SecurityLevel::HIGH)]); + removed + .identity + .set_public_keys(std::collections::BTreeMap::new()); + let mut replaced = identity_holding(&[auth_key(1, SecurityLevel::HIGH)]); + let mut other = auth_key(1, SecurityLevel::HIGH); + other.set_data(vec![3; 33].into()); + replaced + .identity + .set_public_keys(std::collections::BTreeMap::from([(1, other)])); + + for (case, identity) in [("removed", removed), ("replaced", replaced)] { + assert_eq!( + listed_keys(&identity, filter, true, now).count(), + 0, + "{case}" + ); + assert!(!key_eligibility(&identity, &filter, now).0, "{case}"); + } + } + + /// DashPay and username registration choosers offer the key types their + /// automatic choice takes; other transactions the types Platform verifies. + #[test] + fn document_choosers_offer_the_key_types_their_writes_sign_with() { + use crate::model::dpns_usernames::DPNS_SIGNING_KEY_TYPES; + use crate::model::qualified_identity::DASHPAY_SIGNING_KEY_TYPES; + assert_eq!( + TransactionType::ContactRequest.allowed_key_types(), + Some(DASHPAY_SIGNING_KEY_TYPES) + ); + assert_eq!( + TransactionType::UsernameRegistration.allowed_key_types(), + Some(DPNS_SIGNING_KEY_TYPES) + ); + assert_eq!( + TransactionType::DocumentAction.allowed_key_types(), + Some(crate::model::identity_key_usability::PLATFORM_SIGNING_KEY_TYPES) + ); + for token in [TransactionType::TokenTransfer, TransactionType::TokenClaim] { + assert_eq!( + token.allowed_key_types(), + Some(crate::model::identity_key_usability::PLATFORM_SIGNING_KEY_TYPES), + "{token:?}" + ); + } + assert_eq!( + TransactionType::UsernameRegistration.allowed_purposes(), + vec![Purpose::AUTHENTICATION] + ); + } + + /// Regression: every chooser except the token, DashPay and username ones + /// offered keys of any type, including EdDSA and BIP13 keys Platform + /// refuses for every identity-signed transition. + #[test] + fn no_chooser_offers_a_key_type_platform_refuses() { + for transaction_type in [ + TransactionType::RegisterContract, + TransactionType::UpdateContract, + TransactionType::Transfer, + TransactionType::Withdraw, + TransactionType::DocumentAction, + TransactionType::TokenAction, + TransactionType::TokenTransfer, + TransactionType::TokenClaim, + TransactionType::ContactRequest, + TransactionType::UsernameRegistration, + ] { + let types = transaction_type + .allowed_key_types() + .unwrap_or_else(|| panic!("{transaction_type:?} accepts every key type")); + for refused in [KeyType::EDDSA_25519_HASH160, KeyType::BIP13_SCRIPT_HASH] { + assert!( + !types.contains(&refused), + "{transaction_type:?}: {refused:?}" + ); + } + } + } + /// Switching contract drops a document type of the previous contract, so /// no action pairs one contract with another contract's type; a type of /// the newly selected contract stays selected. diff --git a/src/ui/identity/keys/add_key_screen.rs b/src/ui/identity/keys/add_key_screen.rs index eeae10ef8..beac62e50 100644 --- a/src/ui/identity/keys/add_key_screen.rs +++ b/src/ui/identity/keys/add_key_screen.rs @@ -32,7 +32,6 @@ use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::prelude::Identifier; use eframe::egui::{self, Frame, Margin}; use egui::{Color32, RichText, Ui}; -use std::collections::HashSet; use std::sync::{Arc, RwLock}; use zeroize::Zeroizing; @@ -137,12 +136,7 @@ pub struct AddKeyScreen { impl AddKeyScreen { pub fn new(identity: QualifiedIdentity, app_context: &Arc) -> Self { let identity_clone = identity.clone(); - let selected_key = identity_clone.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::MASTER]), - KeyType::all_key_types().into(), - false, - ); + let selected_key = identity_clone.identity_update_signing_key(); let selected_wallet = get_selected_wallet(&identity, None, selected_key) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); @@ -183,12 +177,7 @@ impl AddKeyScreen { app_context: &Arc, ) -> Self { let identity_clone = identity.clone(); - let selected_key = identity_clone.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::MASTER]), - KeyType::all_key_types().into(), - false, - ); + let selected_key = identity_clone.identity_update_signing_key(); let selected_wallet = get_selected_wallet(&identity, None, selected_key) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); @@ -234,12 +223,7 @@ impl AddKeyScreen { app_context: &Arc, ) -> Self { let identity_clone = identity.clone(); - let selected_key = identity_clone.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::MASTER]), - KeyType::all_key_types().into(), - false, - ); + let selected_key = identity_clone.identity_update_signing_key(); let selected_wallet = get_selected_wallet(&identity, None, selected_key) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); diff --git a/src/ui/identity/keys/key_info_screen.rs b/src/ui/identity/keys/key_info_screen.rs index 1c955eadc..2d21819df 100644 --- a/src/ui/identity/keys/key_info_screen.rs +++ b/src/ui/identity/keys/key_info_screen.rs @@ -602,10 +602,21 @@ impl ScreenLike for KeyInfoScreen { self.render_sign_input(ui); } PrivateKeyData::Encrypted(_) => { - ui.label(RichText::new("Key is encrypted").color(text_primary)); + // This version cannot open the per-key envelope of + // early versions; entering the key again files a + // copy it can use in the same place. + ui.label( + RichText::new( + "This key was saved by an older version of Dash Evo Tool and must be entered again before it can be used.", + ) + .color(text_primary), + ); ui.add_space(10.0); - - //todo decrypt key + ui.label(RichText::new("Enter Private Key:").color(text_primary)); + self.private_key_input.show(ui); + if ui.button("Add Private Key").clicked() { + self.validate_and_store_private_key(); + } } PrivateKeyData::AtWalletDerivationPath(derivation_path) => { if self.wallet_open @@ -1170,11 +1181,14 @@ impl KeyInfoScreen { ) }); match updated { - Ok(fresh) => { - // Resolved from the record just persisted, so the screen - // shows exactly what was filed. - self.private_key_data = fresh.private_keys.held_private_key_data(&self.key); - self.identity = fresh; + Ok(edited) => { + // `edited` is the record as changed in memory, holding the + // key in plaintext; the store filed it in the vault. Drop + // that copy and show the record as stored, and clear the + // input so a later removal does not offer the key again. + drop(edited); + self.private_key_input.clear(); + self.reload_identity(); } Err(error) => { // An input refusal dismisses on its own; a record that @@ -2512,6 +2526,170 @@ mod tests { .await; } + /// `identity` with `key` published and its private half held only in the + /// per-key encrypted form of early versions, which this version cannot + /// open. + fn hold_as_legacy(identity: &mut QualifiedIdentity, key: &IdentityPublicKey) { + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + let mut published = identity.identity.public_keys().clone(); + published.insert(key.id(), key.clone()); + identity.identity.set_public_keys(published); + identity.private_keys.insert_at( + (MAIN, key.id()), + ( + QualifiedIdentityPublicKey::from(key.clone()), + PrivateKeyData::Encrypted(rand::random::<[u8; 48]>().to_vec()), + ), + ); + } + + /// The data `identity` holds for `key`, without its wallet details. + fn held_data(identity: &QualifiedIdentity, key: &IdentityPublicKey) -> Option { + identity + .private_keys + .held_private_key_data(key) + .map(|(data, _)| data) + } + + /// Re-entering a key an early version saved in its legacy encrypted form + /// overwrites that slot in the vault. Afterwards the screen holds no + /// plaintext copy, and the input is empty, so removing the key later does + /// not show it again for a one-click re-add. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reentering_a_legacy_key_files_it_in_the_vault_and_forgets_it() { + let (app_context, _dir) = offline_ctx().await; + let (key, secret) = keypair(1, Purpose::AUTHENTICATION); + let mut stored = identity_with(0x5D, &[]); + hold_as_legacy(&mut stored, &key); + let identity_id = stored.identity.id(); + app_context + .insert_local_qualified_identity(&stored, &None) + .expect("insert the record"); + + let held = stored.private_keys.held_private_key_data(&key); + let mut screen = KeyInfoScreen::new(stored, key.clone(), held, &app_context); + screen.private_key_input.set_text(hex::encode(secret)); + screen.validate_and_store_private_key(); + + let on_disk = app_context + .get_local_qualified_identity(&identity_id) + .expect("read back") + .expect("still stored"); + assert_eq!( + held_data(&on_disk, &key), + Some(PrivateKeyData::InVault), + "the legacy slot is overwritten with the vault copy", + ); + assert_eq!( + screen.private_key_data.as_ref().map(|(data, _)| data), + Some(&PrivateKeyData::InVault), + "the screen shows the stored placement, not the plaintext", + ); + assert_eq!( + held_data(&screen.identity, &key), + Some(PrivateKeyData::InVault) + ); + assert!( + screen.private_key_input.is_empty(), + "the entered key does not stay in the input", + ); + + app_context + .wallet_backend() + .expect("backend") + .shutdown() + .await; + } + + /// A key that does not match is refused while the slot holds the legacy + /// encrypted form, and the slot is left as it was. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_wrong_key_does_not_replace_a_legacy_key() { + let (app_context, _dir) = offline_ctx().await; + let (key, _) = keypair(1, Purpose::AUTHENTICATION); + let (_, other_secret) = keypair(2, Purpose::AUTHENTICATION); + let mut stored = identity_with(0x5E, &[]); + hold_as_legacy(&mut stored, &key); + let identity_id = stored.identity.id(); + app_context + .insert_local_qualified_identity(&stored, &None) + .expect("insert the record"); + + let held = stored.private_keys.held_private_key_data(&key); + let mut screen = KeyInfoScreen::new(stored, key.clone(), held, &app_context); + screen.private_key_input.set_text(hex::encode(other_secret)); + screen.validate_and_store_private_key(); + + let on_disk = app_context + .get_local_qualified_identity(&identity_id) + .expect("read back") + .expect("still stored"); + assert!(matches!( + held_data(&on_disk, &key), + Some(PrivateKeyData::Encrypted(_)) + )); + assert!(matches!( + screen.private_key_data, + Some((PrivateKeyData::Encrypted(_), _)) + )); + assert_eq!( + crate::ui::components::message_banner::global_banner_texts(app_context.egui_ctx()), + ["Private key does not match the public key."] + ); + + app_context + .wallet_backend() + .expect("backend") + .shutdown() + .await; + } + + /// On a password-protected identity a re-entered legacy key cannot be + /// stored without its password, so the keyless persist is refused with + /// its own message and the legacy slot is left as it was. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reentering_a_legacy_key_of_a_protected_identity_is_refused() { + let (app_context, _dir) = offline_ctx().await; + let sealed = public_key(0, Purpose::AUTHENTICATION); + let (key, secret) = keypair(1, Purpose::AUTHENTICATION); + let mut stored = protected_identity(&app_context, 0x7D, &sealed, &[]); + hold_as_legacy(&mut stored, &key); + let identity_id = stored.identity.id(); + app_context + .insert_local_qualified_identity(&stored, &None) + .expect("a record with no resident plaintext inserts cleanly"); + + let held = stored.private_keys.held_private_key_data(&key); + let mut screen = KeyInfoScreen::new(stored, key.clone(), held, &app_context); + screen.private_key_input.set_text(hex::encode(secret)); + screen.validate_and_store_private_key(); + + let on_disk = app_context + .get_local_qualified_identity(&identity_id) + .expect("read back") + .expect("still stored"); + assert!(matches!( + held_data(&on_disk, &key), + Some(PrivateKeyData::Encrypted(_)) + )); + assert!(matches!( + screen.private_key_data, + Some((PrivateKeyData::Encrypted(_), _)) + )); + let banner_texts = + crate::ui::components::message_banner::global_banner_texts(app_context.egui_ctx()); + assert!( + banner_texts.contains(&TaskError::IdentityKeyProtectionDowngrade.to_string()), + "got {banner_texts:?}", + ); + + app_context + .wallet_backend() + .expect("backend") + .shutdown() + .await; + } + /// The mirror of the paste refusal: a removal that cannot persist must not /// leave the screen claiming the key is gone. The locked read-modify-write /// edits the record as stored, so a record that vanished while this screen diff --git a/src/ui/identity/mod.rs b/src/ui/identity/mod.rs index 5207fb8f7..45a7ec62f 100644 --- a/src/ui/identity/mod.rs +++ b/src/ui/identity/mod.rs @@ -166,6 +166,17 @@ pub fn removed_identities_banner( } } +/// The wallet that derives `key`, the key the caller signs with, for the +/// unlock prompt. `None` when no key is chosen or no wallet derives it. +pub fn wallet_for_signing_key( + qualified_identity: &QualifiedIdentity, + key: Option<&IdentityPublicKey>, +) -> Option>> { + get_selected_wallet(qualified_identity, None, Some(key?)) + .ok() + .flatten() +} + /// Retrieves the appropriate wallet (if any) associated with the given identity. /// /// # Description @@ -208,13 +219,13 @@ pub fn get_selected_wallet( let dpns_contract = &context.dpns_contract(); // Attempt to fetch the `preorder` document type from the DPNS contract. - let preorder_document_type = dpns_contract + dpns_contract .document_type_for_name("preorder") .map_err(|e| format!("DPNS preorder document type not found: {}", e))?; // Attempt to retrieve the public key from the identity. qualified_identity - .document_signing_key(&preorder_document_type) + .username_registration_key(dpns_contract) .ok_or_else(|| { "Identity doesn't have an authentication key for signing document transitions" .to_string() diff --git a/src/ui/identity/register_dpns_name_screen.rs b/src/ui/identity/register_dpns_name_screen.rs index f2b1152fa..c4b8e4b3e 100644 --- a/src/ui/identity/register_dpns_name_screen.rs +++ b/src/ui/identity/register_dpns_name_screen.rs @@ -12,12 +12,13 @@ use crate::model::dpns::{ validate_dpns_name, }; use crate::model::dpns_usernames::{ - UsernameAvailability, dpns_signing_requirement, missing_registration_wallets, - registration_key_status, + UsernameAvailability, dpns_security_levels, dpns_signing_requirement, + missing_registration_wallets, registration_key_status, }; use crate::model::fee_estimation::{ contest_fee_credits, format_credits_as_dash, username_name_admission_reserve_credits, }; +use crate::model::identity_key_usability::SigningScope; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; @@ -32,7 +33,7 @@ use crate::ui::components::wallet_unlock_popup::{ use crate::ui::components::{ MessageBanner, OptionOverlayExt, OverlayConfig, OverlayHandle, ResultBannerExt, }; -use crate::ui::helpers::{TransactionType, add_key_chooser_with_doc_type}; +use crate::ui::helpers::{TransactionType, add_key_chooser_with_security_levels}; use crate::ui::identity::username_copy::{ self, AvailabilityRow, Tone, USERNAME_RULES, availability_line, offers_suggestions, }; @@ -49,7 +50,7 @@ use egui::{RichText, Ui}; use std::sync::{Arc, RwLock}; use std::time::{Duration, Instant}; -use super::get_selected_wallet; +use super::{get_selected_wallet, wallet_for_signing_key}; /// Wait after the last keystroke before asking the network about a name. const AVAILABILITY_DEBOUNCE: Duration = Duration::from_millis(400); @@ -198,10 +199,11 @@ impl RegisterDpnsNameScreen { } } - /// Pick the wallet the identity's signing key comes from, to offer its unlock. + /// Pick the wallet the registration's signing key comes from, to offer + /// its unlock. fn select_wallet(&mut self) { self.selected_wallet = self.selected_qualified_identity.as_ref().and_then(|qi| { - get_selected_wallet(qi, Some(&self.app_context), None) + registration_wallet(qi, self.selected_key.as_ref(), &self.app_context) .or_show_error(self.app_context.egui_ctx()) .unwrap_or(None) }); @@ -639,31 +641,38 @@ impl RegisterDpnsNameScreen { } }); + let key_before = self.selected_key.as_ref().map(|key| key.id()); if self.app_context.user_role().at_least(UserRole::Power) { egui::CollapsingHeader::new("Advanced") .default_open(false) .show(ui, |ui| { - let document_type = self - .app_context - .dpns_contract() - .document_type_cloned_for_name("domain") - .ok(); + let dpns_contract = self.app_context.dpns_contract(); + // The levels both registration documents accept, as the + // automatic choice and the backend's check of a chosen + // key use them. + let levels = dpns_security_levels(dpns_signing_requirement(&dpns_contract)); if self.selected_key.is_none() { - self.selected_key = document_type - .as_ref() - .and_then(|doc_type| identity.document_signing_key(&doc_type.as_ref())) - .cloned(); + self.selected_key = + identity.username_registration_key(&dpns_contract).cloned(); } - action |= add_key_chooser_with_doc_type( + action |= add_key_chooser_with_security_levels( ui, &self.app_context, &identity, &mut self.selected_key, - TransactionType::DocumentAction, - document_type.as_ref(), + TransactionType::UsernameRegistration, + &levels, + SigningScope::ContractWide { + contract_id: dpns_contract.id(), + }, ); }); } + // The wallet to unlock follows the key the registration is signed + // with, so a key chosen under Advanced brings its own wallet. + if self.selected_key.as_ref().map(|key| key.id()) != key_before { + self.select_wallet(); + } if let Some(wallet) = &self.selected_wallet { if !self.wallet_open_attempted { @@ -1030,10 +1039,74 @@ impl ScreenLike for RegisterDpnsNameScreen { } } +/// The wallet to unlock for a registration: the one holding the key the +/// user chose under Advanced, or, without a choice, the one holding the key +/// picked automatically. +fn registration_wallet( + identity: &QualifiedIdentity, + chosen_key: Option<&IdentityPublicKey>, + app_context: &AppContext, +) -> Result>>, String> { + match chosen_key { + Some(key) => Ok(wallet_for_signing_key(identity, Some(key))), + None => get_selected_wallet(identity, Some(app_context), None), + } +} + #[cfg(test)] mod tests { use super::*; + /// Regression: the unlock prompt and the pay gate followed the + /// automatically picked key's wallet, not the wallet of the key the user + /// chose under Advanced. + #[test] + fn the_unlock_wallet_follows_the_chosen_registration_key() { + use crate::context::test_support::{ + auth_key, bare_user_identity, hold_as_wallet_reference, + }; + use crate::model::wallet::WalletSeed; + use crate::model::wallet::test_support::open_wallet; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::platform::Identifier; + + let dir = tempfile::tempdir().unwrap(); + let ctx = crate::context::test_support::test_app_context(dir.path()); + let auto_wallet = open_wallet(); + let mut chosen_wallet = open_wallet(); + let WalletSeed::Open(seed) = &mut chosen_wallet.wallet_seed else { + unreachable!("open_wallet builds an open wallet"); + }; + seed.wallet_info.seed_hash = [7; 32]; + let (auto_hash, chosen_hash) = (auto_wallet.seed_hash(), chosen_wallet.seed_hash()); + + // Key 1 (HIGH) is the automatic choice; key 2 (CRITICAL) also signs. + let auto_key = auth_key(1, SecurityLevel::HIGH); + let chosen_key = auth_key(2, SecurityLevel::CRITICAL); + let mut identity = bare_user_identity(Identifier::new([0xD2; 32]), ctx.network()); + identity.identity.set_public_keys( + [(1, auto_key), (2, chosen_key.clone())] + .into_iter() + .collect(), + ); + hold_as_wallet_reference(&mut identity, 1, auto_hash); + hold_as_wallet_reference(&mut identity, 2, chosen_hash); + let auto_wallet = Arc::new(RwLock::new(auto_wallet)); + let chosen_wallet = Arc::new(RwLock::new(chosen_wallet)); + identity + .associated_wallets + .insert(auto_hash, Arc::clone(&auto_wallet)); + identity + .associated_wallets + .insert(chosen_hash, Arc::clone(&chosen_wallet)); + + let automatic = registration_wallet(&identity, None, &ctx).unwrap(); + assert!(automatic.is_some_and(|wallet| Arc::ptr_eq(&wallet, &auto_wallet))); + let chosen = registration_wallet(&identity, Some(&chosen_key), &ctx).unwrap(); + assert!(chosen.is_some_and(|wallet| Arc::ptr_eq(&wallet, &chosen_wallet))); + } + #[test] fn review_regression_unrelated_error_keeps_paid_registration_blocked() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/ui/identity/transfer_screen.rs b/src/ui/identity/transfer_screen.rs index 7b9335bbd..fddd7c1c3 100644 --- a/src/ui/identity/transfer_screen.rs +++ b/src/ui/identity/transfer_screen.rs @@ -4,7 +4,8 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::{format_credits_as_dash, max_spendable_credits}; -use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; +use crate::model::qualified_identity::{QualifiedIdentity, SigningKeyStatus}; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -24,7 +25,7 @@ use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Identifier, IdentityPublicKey}; use eframe::egui::{self, Frame, Margin, Ui}; @@ -92,13 +93,8 @@ impl TransferScreen { .unwrap_or_default(); let max_amount = identity.identity.balance(); - let identity_clone = identity.identity.clone(); - let selected_key = identity_clone.get_first_public_key_matching( - Purpose::TRANSFER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ); + let identity_clone = identity.clone(); + let selected_key = identity_clone.transfer_signing_key(); let selected_wallet = get_selected_wallet(&identity, None, selected_key).unwrap_or_else(|e| { MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error) @@ -139,6 +135,7 @@ impl TransferScreen { &self.identity, &mut self.selected_key, TransactionType::Transfer, + SigningScope::NonBatch, ) } @@ -603,30 +600,41 @@ impl ScreenLike for TransferScreen { return inner_action; } - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !self.identity.identity.public_keys().is_empty() + // Outside developer mode the form opens exactly when the key its + // constructor pre-selects can sign, as the backend's own choice. + let key_status = if self.app_context.user_role().at_least(UserRole::Developer) { + if self.identity.identity.public_keys().is_empty() { + SigningKeyStatus::NoneHeld + } else { + SigningKeyStatus::Usable + } } else { - !self.identity.available_transfer_keys().is_empty() + self.identity.transfer_key_status() }; + let has_keys = key_status == SigningKeyStatus::Usable; let key_for_info = key_info_when_available( has_keys, - self.identity.identity.get_first_public_key_matching( + self.identity.matching_key_now(KeyRequirements::new( Purpose::TRANSFER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ), + &SecurityLevel::full_range(), + SigningScope::NonBatch, + )), ); if !has_keys { - ui.colored_label( - egui::Color32::DARK_RED, + let message = if key_status == SigningKeyStatus::HeldButUnusable { + "No transfer key on this device can sign a transfer from this identity. \ + Its transfer keys are disabled, expired, or not available on this device. \ + Add a new key, or load the wallet that holds one, then try again." + .to_string() + } else { format!( "You do not have any transfer keys loaded for this {identity_type} identity.", identity_type = self.identity.identity_type - ), - ); + ) + }; + ui.colored_label(egui::Color32::DARK_RED, message); ui.add_space(10.0); ui.horizontal(|ui| { diff --git a/src/ui/identity/username_copy.rs b/src/ui/identity/username_copy.rs index 05b365664..4b01f8fcf 100644 --- a/src/ui/identity/username_copy.rs +++ b/src/ui/identity/username_copy.rs @@ -180,6 +180,10 @@ pub const RESTORE_KEYS_TO_REGISTER: &str = "The key this identity needs to regis /// version, when no restore is offered in place. pub const RESTORE_FROM_PREVIOUS_VERSION_TO_REGISTER: &str = "The key this identity needs to register usernames is not on this device. If you updated from an earlier version of Dash Evo Tool, open Settings and use Restore from Previous Version to bring back missing wallets and keys."; +/// Why an identity cannot register when its key was saved by an early version +/// in a form this version cannot open. +pub const REENTER_KEY_TO_REGISTER: &str = "The key this identity needs to register usernames was saved by an older version of Dash Evo Tool and must be entered again. Open this identity's keys and enter the key to register usernames."; + /// Why an identity cannot register when its key was never saved on this device. pub const ENTER_KEY_TO_REGISTER: &str = "The key this identity needs to register usernames is not on this device. Open this identity's keys and enter the key to use it here."; diff --git a/src/ui/identity/usernames_card.rs b/src/ui/identity/usernames_card.rs index deba183e8..b0884b527 100644 --- a/src/ui/identity/usernames_card.rs +++ b/src/ui/identity/usernames_card.rs @@ -22,8 +22,9 @@ use crate::ui::components::qr_code_image::QrCodeImage; use crate::ui::identity::register_dpns_name_screen::{RegisterDpnsNameSource, status_line}; use crate::ui::identity::username_copy::{ ENTER_KEY_TO_REGISTER, IMPORT_OR_RESTORE_WALLET_TO_REGISTER, IMPORT_WALLET_TO_REGISTER, - NEEDS_KEY_TO_REGISTER, RESTORE_FROM_PREVIOUS_VERSION_TO_REGISTER, RESTORE_KEYS_TO_REGISTER, - Tone, finished_request_detail, pending_request_detail, phase_label, registered_on_line, + NEEDS_KEY_TO_REGISTER, REENTER_KEY_TO_REGISTER, RESTORE_FROM_PREVIOUS_VERSION_TO_REGISTER, + RESTORE_KEYS_TO_REGISTER, Tone, finished_request_detail, pending_request_detail, phase_label, + registered_on_line, }; use crate::ui::masternodes::KeyVocabulary; use crate::ui::state::legacy_recovery::LegacyRecoveryState; @@ -137,6 +138,9 @@ pub(crate) enum KeyHint { PreviousVersion, /// An earlier version saved nothing: enter the key. EnterKey, + /// An early version saved the key in a form this version cannot open: + /// enter it again. + ReenterKey, /// The key comes from a wallet that is not on this device: import it. ImportWallet, /// The same on an updated install: import the wallet or restore from Settings. @@ -155,6 +159,7 @@ impl KeyHint { match status { RegistrationKeyStatus::Ready => None, RegistrationKeyStatus::NoSuitableKey => Some(Self::AddKey), + RegistrationKeyStatus::KeySavedByOlderVersion => Some(Self::ReenterKey), RegistrationKeyStatus::KeyNotOnDevice | RegistrationKeyStatus::KeyWalletNotOnDevice if restorable_here => { @@ -176,6 +181,7 @@ impl KeyHint { Self::Restore => RESTORE_KEYS_TO_REGISTER, Self::PreviousVersion => RESTORE_FROM_PREVIOUS_VERSION_TO_REGISTER, Self::EnterKey => ENTER_KEY_TO_REGISTER, + Self::ReenterKey => REENTER_KEY_TO_REGISTER, Self::ImportWallet => IMPORT_WALLET_TO_REGISTER, Self::ImportWalletOrPreviousVersion => IMPORT_OR_RESTORE_WALLET_TO_REGISTER, } @@ -211,7 +217,7 @@ pub(crate) fn key_hint_links( // has nowhere to enter a key this identity already has an entry for. let keys_link = match hint { KeyHint::AddKey => Some("Add a key"), - KeyHint::PreviousVersion | KeyHint::EnterKey => Some("Open keys"), + KeyHint::PreviousVersion | KeyHint::EnterKey | KeyHint::ReenterKey => Some("Open keys"), KeyHint::Restore | KeyHint::ImportWallet | KeyHint::ImportWalletOrPreviousVersion => None, }; if keys_link.is_some_and(|label| ui.link(label).clicked()) { @@ -893,6 +899,23 @@ mod tests { ); } + /// A key an early version saved in a form this version cannot open is + /// entered again; restoring from the previous version cannot open it + /// either, so no restore is offered for it. + #[test] + fn a_key_saved_by_an_older_version_is_entered_again() { + let status = RegistrationKeyStatus::KeySavedByOlderVersion; + assert!(!status.key_is_unusable_here()); + for (restorable_here, updated_install) in + [(true, true), (true, false), (false, true), (false, false)] + { + assert_eq!( + KeyHint::for_status(status, restorable_here, updated_install), + Some(KeyHint::ReenterKey) + ); + } + } + /// The hub reads the identity again on every frame, so a wallet imported /// while the card is open enables the button without reopening anything. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/ui/identity/withdraw_screen.rs b/src/ui/identity/withdraw_screen.rs index f1d3f8789..5cc1eef4c 100644 --- a/src/ui/identity/withdraw_screen.rs +++ b/src/ui/identity/withdraw_screen.rs @@ -4,7 +4,8 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::{format_credits_as_dash, max_spendable_credits}; -use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; +use crate::model::identity_key_usability::{KeyRequirements, SigningScope}; +use crate::model::qualified_identity::{IdentityType, QualifiedIdentity, SigningKeyStatus}; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -24,7 +25,7 @@ use dash_sdk::dashcore_rpc::dashcore::{Address, Network}; use dash_sdk::dpp::fee::Credits; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dash_sdk::dpp::identity::{KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::identity::{Purpose, SecurityLevel}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::IdentityPublicKey; use eframe::egui::{self, Frame, Margin, Ui}; @@ -67,15 +68,12 @@ pub struct WithdrawalScreen { impl WithdrawalScreen { pub fn new(identity: QualifiedIdentity, app_context: &Arc) -> Self { let max_amount = identity.identity.balance(); - // Only pre-select a withdrawal key whose private material is held locally - // (TRANSFER preferred, OWNER fallback). Pre-selecting an on-chain-only key - // the signer cannot use is what surfaced the raw signing error. This is the - // same invariant `can_attempt_withdrawal` gates on for every role, so a - // `None` here means the gate itself should have kept this screen from - // being reachable in the first place. - let selected_key: Option = identity - .default_withdrawal_key() - .map(|qk| qk.identity_public_key.clone()); + // Pre-select the key the backend signs with when none is chosen: one + // this device can sign with and Platform accepts (TRANSFER preferred, + // OWNER fallback). `can_attempt_withdrawal` gates on the same choice + // for every role, so a `None` here means the gate itself should have + // kept this screen from being reachable in the first place. + let selected_key: Option = identity.withdrawal_signing_key().cloned(); // With no key there is nothing to resolve a wallet from; skip the call so // get_selected_wallet's "no key provided" Err path stays unreachable here. let selected_wallet = match selected_key.as_ref() { @@ -111,6 +109,7 @@ impl WithdrawalScreen { &self.identity, &mut self.selected_key, TransactionType::Withdraw, + SigningScope::NonBatch, ) } @@ -425,16 +424,20 @@ impl ScreenLike for WithdrawalScreen { }); ui.add_space(10.0); - if !self - .identity - .can_attempt_withdrawal(self.app_context.user_role()) - { - ui.colored_label( - egui::Color32::DARK_RED, + // The same choice `can_attempt_withdrawal` gates the entry points on. + let key_status = self.identity.withdrawal_key_status(); + if key_status != SigningKeyStatus::Usable { + let message = if key_status == SigningKeyStatus::HeldButUnusable { + "No key on this device can approve a withdrawal from this identity. Its \ + keys are disabled, expired, or not available on this device. Load or \ + import a key that can send funds from this identity, or load the wallet \ + that holds one, then try again." + } else { "This identity has no loaded key that can approve a withdrawal. Load or \ import a key that can send funds from this identity, or use a different \ - identity.", - ); + identity." + }; + ui.colored_label(egui::Color32::DARK_RED, message); ui.add_space(10.0); if self.identity.identity_type != IdentityType::User { @@ -443,18 +446,16 @@ impl ScreenLike for WithdrawalScreen { ui.add_space(10.0); } - let owner_key = self.identity.identity.get_first_public_key_matching( + let owner_key = self.identity.matching_key_now(KeyRequirements::new( Purpose::OWNER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ); - let transfer_key = self.identity.identity.get_first_public_key_matching( + &SecurityLevel::full_range(), + SigningScope::NonBatch, + )); + let transfer_key = self.identity.matching_key_now(KeyRequirements::new( Purpose::TRANSFER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ); + &SecurityLevel::full_range(), + SigningScope::NonBatch, + )); if let Some(owner_key) = owner_key { if ui.button("Check Owner Key").clicked() { diff --git a/src/ui/tokens/claim_tokens_screen.rs b/src/ui/tokens/claim_tokens_screen.rs index 89e8a84c4..3ee46a2cc 100644 --- a/src/ui/tokens/claim_tokens_screen.rs +++ b/src/ui/tokens/claim_tokens_screen.rs @@ -1,6 +1,7 @@ use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::model::datetime::local_date_time_from_unix_millis; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::SigningScope; use crate::model::token::once_per_identity_amount; use crate::model::user_role::UserRole; use crate::ui::components::Component; @@ -32,7 +33,7 @@ use crate::backend_task::error::TaskError; use crate::backend_task::tokens::TokenTask; use crate::context::AppContext; use crate::model::qualified_contract::QualifiedContract; -use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; +use crate::model::qualified_identity::{QualifiedIdentity, SigningKeyStatus}; use crate::model::wallet::Wallet; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{MessageType, Screen, ScreenLike}; @@ -103,22 +104,8 @@ impl ClaimTokensScreen { .find(|id| id.identity.id() == identity_token_basic_info.identity_id); let (selected_key, selected_wallet) = if let Some(ref id) = identity { - let identity_inner = &id.identity; - let key = identity_inner - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) - .or_else(|| { - identity_inner.get_first_public_key_matching( - Purpose::TRANSFER, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) - }) + let key = id + .single_token_transition_key(identity_token_basic_info.contract_id) .cloned(); let selected_wallet = get_selected_wallet(id, None, key.as_ref()).unwrap_or(None); @@ -453,6 +440,15 @@ impl ScreenLike for ClaimTokensScreen { { self.identity = Some(updated); } + if let Some(identity) = &self.identity { + let contract_id = self.identity_token_basic_info.contract_id; + super::pick_key_if_none( + identity, + &mut self.selected_key, + &mut self.selected_wallet, + |identity| identity.single_token_transition_key(contract_id), + ); + } self.once_per_identity_claimed_at = load_once_per_identity_claim(&self.app_context, &self.identity_token_basic_info); } @@ -502,27 +498,33 @@ impl ScreenLike for ClaimTokensScreen { ui.add_space(10.0); // Check if user has any auth keys - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !identity.identity.public_keys().is_empty() - } else { - match identity.identity_type { - IdentityType::User => !identity - .available_authentication_keys_with_critical_security_level() - .is_empty(), - IdentityType::Masternode | IdentityType::Evonode => { - !identity.available_transfer_keys().is_empty() - } + let key_status = if self.app_context.user_role().at_least(UserRole::Developer) { + if identity.identity.public_keys().is_empty() { + SigningKeyStatus::NoneHeld + } else { + SigningKeyStatus::Usable } + } else { + claim_key_status(identity, self.identity_token_basic_info.contract_id) }; - if !has_keys { - ui.colored_label( - Color32::RED, + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.show_advanced_options = true; + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { + let message = if key_status == SigningKeyStatus::HeldButUnusable { + "No key on this device can sign this token claim. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string() + } else { format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - identity.identity_type, - ), - ); + "No CRITICAL authentication or transfer key is loaded for this {identity_type} identity.", + identity_type = identity.identity_type, + ) + }; + ui.colored_label(Color32::RED, message); ui.add_space(10.0); let first_key = identity.identity.get_first_public_key_matching( @@ -592,12 +594,19 @@ impl ScreenLike for ClaimTokensScreen { if self.show_advanced_options { ui.heading("1. Select the key to sign the Claim transition"); ui.add_space(10.0); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.app_context, identity, &mut self.selected_key, TransactionType::TokenClaim, + SigningScope::ContractWide { + contract_id: self.identity_token_basic_info.contract_id, + }, ); ui.add_space(10.0); } @@ -787,6 +796,17 @@ impl ScreenLike for ClaimTokensScreen { } } +/// Whether `identity` can sign a claim on `contract_id`, the gate in front +/// of the form outside developer mode: whether +/// [`QualifiedIdentity::single_token_transition_key`], the claim's key +/// choice for every identity type, picks a key. +fn claim_key_status( + identity: &QualifiedIdentity, + contract_id: dash_sdk::platform::Identifier, +) -> SigningKeyStatus { + identity.single_token_transition_key_status(contract_id) +} + /// The claim confirmation text: which distribution of `token` is claimed and, /// for the once-per-identity distribution, how much. fn claim_confirmation_text( @@ -829,11 +849,12 @@ fn load_once_per_identity_claim( mod tests { use super::*; use crate::context::test_support::identity_public_key; - use crate::model::qualified_identity::IdentityStatus; + use crate::model::qualified_identity::{IdentityStatus, IdentityType}; use crate::ui::tokens::tokens_screen::IdentityTokenBasicInfo; use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; use dash_sdk::dpp::data_contract::associated_token::token_distribution_rules::TokenDistributionRules; use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::Identifier; @@ -1029,4 +1050,180 @@ mod tests { )); assert_eq!(screen.once_per_identity_claimed_at, None); } + + /// Regression: the claim key falls back to a CRITICAL TRANSFER key, but + /// the gate in front of the form counted only CRITICAL AUTHENTICATION + /// keys for a user identity, so one holding only a transfer key was told + /// it had no key. + #[test] + fn the_claim_gate_admits_the_keys_the_claim_signs_with() { + use crate::context::test_support::{auth_key, identity_holding}; + let mut transfer = auth_key(1, SecurityLevel::CRITICAL); + transfer.set_purpose(Purpose::TRANSFER); + let identity = identity_holding(&[transfer]); + assert_eq!(identity.identity_type, IdentityType::User); + let contract_id = Identifier::new([0x70; 32]); + assert_eq!( + claim_key_status(&identity, contract_id), + SigningKeyStatus::Usable + ); + + assert_eq!( + claim_key_status( + &identity_holding(&[auth_key(1, SecurityLevel::HIGH)]), + contract_id + ), + SigningKeyStatus::NoneHeld + ); + } + + /// Regression: the claim gate counted any filed CRITICAL key, while the + /// claim signs only with a live key valid for the token's contract whose + /// private half this device can read, so an identity holding only an + /// expired, elsewhere-bound or unreadable key got the form with Claim + /// disabled. The gate and the claim's key choice now agree. + #[test] + fn the_claim_gate_refuses_keys_the_claim_cannot_sign_with() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use crate::model::qualified_identity::PrivateKeyTarget; + use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + + let contract_id = Identifier::new([0x70; 32]); + let expired = + identity_holding(&[auth_key(1, SecurityLevel::CRITICAL).with_limits(None, Some(1))]); + let elsewhere = identity_holding(&[bound_to( + auth_key(1, SecurityLevel::CRITICAL), + ContractBounds::SingleContract { + id: Identifier::new([0x09; 32]), + }, + )]); + let key = auth_key(1, SecurityLevel::CRITICAL); + let mut unreadable = identity_holding(std::slice::from_ref(&key)); + unreadable.private_keys.insert_at( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + ( + QualifiedIdentityPublicKey { + identity_public_key: key, + in_wallet_at_derivation_path: None, + }, + PrivateKeyData::Encrypted(vec![1; 48]), + ), + ); + for (case, identity) in [ + ("expired", expired), + ("bound elsewhere", elsewhere), + ("not readable here", unreadable), + ] { + assert!( + identity.single_token_transition_key(contract_id).is_none(), + "{case}" + ); + assert_eq!( + claim_key_status(&identity, contract_id), + SigningKeyStatus::HeldButUnusable, + "{case}" + ); + } + } + + /// Regression: the claim key was picked only when the screen opened, so + /// once the identity's key became usable (expiry extended, key added or + /// its wallet loaded) a refresh opened the form with no key to sign. + /// A refresh picks it then, with its wallet, and keeps a manual choice. + #[test] + fn a_refresh_picks_the_claim_key_that_became_usable() { + use crate::ui::tokens::tests::{ + identity_with_expired_key, store_identity_with_usable_wallet_key, + }; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let (mut screen, _dir) = screen_for(once_per_identity_token(5)); + screen.identity = Some(identity_with_expired_key()); + screen.selected_key = None; + screen.selected_wallet = None; + let seed_hash = store_identity_with_usable_wallet_key(&screen.app_context); + + screen.refresh(); + assert_eq!(screen.selected_key.as_ref().map(|key| key.id()), Some(1)); + let wallet = screen.selected_wallet.as_ref().expect("the key's wallet"); + assert_eq!(wallet.read().expect("wallet").seed_hash(), seed_hash); + + let manual = identity_public_key( + 7, + Purpose::AUTHENTICATION, + SecurityLevel::CRITICAL, + KeyType::ECDSA_SECP256K1, + vec![2; 33], + ); + screen.selected_key = Some(manual); + screen.refresh(); + assert_eq!(screen.selected_key.as_ref().map(|key| key.id()), Some(7)); + } + + /// Regression: a claim whose only key is bound to a contract group closed + /// before the key chooser. The form now opens its own Advanced Options + /// with a note saying why, and leaves the app's role as it was. + #[test] + fn a_contract_group_key_opens_the_claims_chooser_with_a_note() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + use egui_kittest::kittest::Queryable; + let (mut screen, _dir) = screen_for(once_per_identity_token(5)); + let role = screen.app_context.user_role(); + screen.identity = Some(identity_holding(&[bound_to( + auth_key(1, SecurityLevel::CRITICAL), + ContractBounds::ContractGroup { + id: Identifier::new([0x6A; 32]), + }, + )])); + screen.selected_key = None; + + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(1280.0, 900.0)) + .build_ui_state( + |ui, screen: &mut ClaimTokensScreen| { + screen.ui(ui); + }, + screen, + ); + harness.run(); + assert!(harness.state().show_advanced_options); + assert!( + harness + .query_by_label(super::super::CONTRACT_GROUP_KEY_NOTE) + .is_some() + ); + assert_eq!(harness.state().app_context.user_role(), role); + } + + /// Regression: a node's claim gate counted any filed transfer key, while + /// the claim signs with the key `single_token_transition_key` picks, so a + /// node holding only an expired one got a form that could not sign. + #[test] + fn a_nodes_claim_gate_follows_the_claims_key_choice() { + use crate::context::test_support::{auth_key, identity_holding}; + let contract_id = Identifier::new([0x70; 32]); + let transfer = |expires_at| { + let mut key = auth_key(1, SecurityLevel::CRITICAL).with_limits(None, expires_at); + key.set_purpose(Purpose::TRANSFER); + key + }; + for identity_type in [IdentityType::Masternode, IdentityType::Evonode] { + let mut expired = identity_holding(&[transfer(Some(1))]); + expired.identity_type = identity_type; + assert_eq!( + claim_key_status(&expired, contract_id), + SigningKeyStatus::HeldButUnusable, + "{identity_type:?}" + ); + let mut live = identity_holding(&[transfer(None)]); + live.identity_type = identity_type; + assert_eq!( + claim_key_status(&live, contract_id), + SigningKeyStatus::Usable, + "{identity_type:?}" + ); + } + } } diff --git a/src/ui/tokens/direct_token_purchase_screen.rs b/src/ui/tokens/direct_token_purchase_screen.rs index f2fcefdc5..2c21c1733 100644 --- a/src/ui/tokens/direct_token_purchase_screen.rs +++ b/src/ui/tokens/direct_token_purchase_screen.rs @@ -1,3 +1,5 @@ +use crate::model::identity_key_usability::SigningScope; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use std::collections::HashSet; use std::sync::{Arc, RwLock}; @@ -16,7 +18,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::user_role::UserRole; +use crate::model::qualified_identity::SigningKeyStatus; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; @@ -82,22 +84,19 @@ impl PurchaseTokenScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key = identity_token_info .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + .token_action_key(identity_token_info.data_contract.contract.id()) .cloned(); // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); - None - }); + let selected_wallet = possible_key + .as_ref() + .map_or(Ok(None), |key| { + get_selected_wallet(&identity_token_info.identity, None, Some(key)) + }) + .unwrap_or_else(|e| { + MessageBanner::set_global(app_context.egui_ctx(), &e, MessageType::Error); + None + }); Self { identity_token_info, @@ -404,6 +403,13 @@ impl ScreenLike for PurchaseTokenScreen { { self.identity_token_info.identity = updated_identity; } + let contract_id = self.identity_token_info.data_contract.contract.id(); + super::pick_key_if_none( + &self.identity_token_info.identity, + &mut self.selected_key, + &mut self.selected_wallet, + |identity| identity.token_action_key(contract_id), + ); } fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { @@ -448,29 +454,24 @@ impl ScreenLike for PurchaseTokenScreen { }); ui.add_space(10.0); - // Check if user has any auth keys - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !self - .identity_token_info - .identity - .identity - .public_keys() - .is_empty() - } else { - !self - .identity_token_info - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; + // Open the form only when the action's key choice picks a key. + let key_status = super::token_action_key_status( + &self.app_context, + &self.identity_token_info.identity, + self.identity_token_info.data_contract.contract.id(), + ); - if !has_keys { + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.show_advanced_options = true; + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { ui.colored_label( DashColors::error_color(dark_mode), - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity_token_info.identity.identity_type, - ), + super::token_action_key_message(key_status, &self.identity_token_info.identity), ); ui.add_space(10.0); @@ -532,12 +533,19 @@ impl ScreenLike for PurchaseTokenScreen { if self.show_advanced_options { ui.heading("1. Select the key to sign the Purchase transaction"); ui.add_space(10.0); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.app_context, &self.identity_token_info.identity, &mut self.selected_key, TransactionType::TokenAction, + SigningScope::ContractWide { + contract_id: self.identity_token_info.data_contract.contract.id(), + }, ); ui.add_space(10.0); ui.separator(); diff --git a/src/ui/tokens/mint_tokens_screen.rs b/src/ui/tokens/mint_tokens_screen.rs index 5c2a56b37..63c706018 100644 --- a/src/ui/tokens/mint_tokens_screen.rs +++ b/src/ui/tokens/mint_tokens_screen.rs @@ -506,4 +506,30 @@ mod tests { let mintable = MintAction::new(&token_info(&context, None, true), &context); assert_eq!(mintable.disabled_reason(), FILL_IN_THE_FORM); } + + /// Regression: with no automatic key (here only a contract-group key, for + /// the user to choose) the constructor still looked up the unlock wallet + /// for no key and raised its error banner over a form that is fine. + #[test] + fn opening_the_form_without_an_automatic_key_raises_no_wallet_error() { + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + let dir = tempfile::tempdir().unwrap(); + let context = crate::context::test_support::test_app_context(dir.path()); + let mut info = token_info(&context, None, false); + info.identity = identity_holding(&[bound_to( + auth_key(1, SecurityLevel::CRITICAL), + ContractBounds::ContractGroup { + id: Identifier::from([0x6A; 32]), + }, + )]); + + let _screen = MintTokensScreen::new(info, &context); + let banners = + crate::ui::components::message_banner::global_banner_texts(context.egui_ctx()); + assert!( + !banners.iter().any(|text| text.contains("No key provided")), + "{banners:?}" + ); + } } diff --git a/src/ui/tokens/mod.rs b/src/ui/tokens/mod.rs index bca5c5c61..13b15e6bf 100644 --- a/src/ui/tokens/mod.rs +++ b/src/ui/tokens/mod.rs @@ -17,10 +17,14 @@ pub mod update_token_config; pub mod view_token_claims_screen; use crate::context::AppContext; -use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::qualified_identity::{QualifiedIdentity, SigningKeyStatus}; +use crate::model::user_role::UserRole; +use crate::model::wallet::Wallet; use crate::ui::MessageType; use crate::ui::components::MessageBanner; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::platform::IdentityPublicKey; +use std::sync::{Arc, RwLock}; /// Loads local identities, displaying an error banner on failure. pub fn load_identities_with_banner(app_context: &AppContext) -> Vec { @@ -59,3 +63,117 @@ pub fn validate_signing_key( } } } + +/// The gate in front of a token action form (mint, burn, freeze, pause, +/// pricing, purchase, configuration updates). Outside developer mode it opens +/// exactly when [`QualifiedIdentity::token_action_key`] picks a key, the key +/// the form's constructor selects, so an open form can always sign; with +/// [`SigningKeyStatus::ManualChoiceOnly`] it opens onto the key chooser. +pub(crate) fn token_action_key_status( + app_context: &AppContext, + identity: &QualifiedIdentity, + contract_id: dash_sdk::platform::Identifier, +) -> SigningKeyStatus { + if app_context.user_role().at_least(UserRole::Developer) { + if identity.identity.public_keys().is_empty() { + SigningKeyStatus::NoneHeld + } else { + SigningKeyStatus::Usable + } + } else { + identity.token_action_key_status(contract_id) + } +} + +/// After `identity` was refreshed, picks the automatic signing key with +/// `pick` and its unlock wallet when no key is chosen yet, so a key that +/// became usable since the form opened is the one it signs with. A chosen +/// key, picked automatically or by hand, is kept. +pub(crate) fn pick_key_if_none( + identity: &QualifiedIdentity, + key: &mut Option, + wallet: &mut Option>>, + pick: impl FnOnce(&QualifiedIdentity) -> Option<&IdentityPublicKey>, +) { + if key.is_some() { + return; + } + *key = pick(identity).cloned(); + *wallet = crate::ui::identity::wallet_for_signing_key(identity, key.as_ref()); +} + +/// Why a token form opened with its key chooser shown +/// ([`SigningKeyStatus::ManualChoiceOnly`]). +pub(crate) const CONTRACT_GROUP_KEY_NOTE: &str = "No key can be chosen automatically for this action, because the only key that can sign it is limited to a contract group. Choose that key below if the group includes this token's contract."; + +/// Why a token action form stays closed for `identity` at `status`. +pub(crate) fn token_action_key_message( + status: SigningKeyStatus, + identity: &QualifiedIdentity, +) -> String { + match status { + SigningKeyStatus::HeldButUnusable => "No key on this device can sign this token action. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string(), + SigningKeyStatus::Usable + | SigningKeyStatus::ManualChoiceOnly + | SigningKeyStatus::NoneHeld => format!( + "No authentication keys with CRITICAL security level found for this {identity_type} identity.", + identity_type = identity.identity_type, + ), + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use crate::context::test_support::{auth_key, bound_to, identity_holding}; + use dash_sdk::dpp::identity::SecurityLevel; + use dash_sdk::dpp::identity::identity_public_key::contract_bounds::ContractBounds; + use dash_sdk::platform::Identifier; + + /// The identity [`identity_holding`] builds, holding only an expired + /// CRITICAL authentication key `1`: no key can sign a token action. + pub(crate) fn identity_with_expired_key() -> QualifiedIdentity { + identity_holding(&[auth_key(1, SecurityLevel::CRITICAL).with_limits(None, Some(1))]) + } + + /// Stores in `ctx` the same identity holding a live key `1` from a wallet + /// loaded in `ctx`, as a screen reads it on refresh once the key became + /// usable, and returns that wallet's seed hash. + pub(crate) fn store_identity_with_usable_wallet_key( + ctx: &AppContext, + ) -> crate::model::wallet::WalletSeedHash { + use crate::wallet_backend::DetKv; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use std::sync::{Arc, RwLock}; + let wallet = crate::model::wallet::test_support::open_wallet(); + let seed_hash = wallet.seed_hash(); + ctx.wallet_context() + .insert_test_wallet(seed_hash, Arc::new(RwLock::new(wallet))); + let mut identity = identity_holding(&[auth_key(1, SecurityLevel::CRITICAL)]); + crate::context::test_support::hold_as_wallet_reference(&mut identity, 1, seed_hash); + ctx.set_det_kv_override_for_test(DetKv::from_store(Arc::new(InMemoryKv::default()))); + ctx.insert_local_qualified_identity(&identity, &Some((seed_hash, 0))) + .expect("store identity"); + seed_hash + } + + /// Regression: for a Power user whose only key is bound to a contract + /// group, the token action gate closed before the Advanced chooser that + /// offers that key. + #[test] + fn a_power_users_contract_group_key_opens_the_chooser() { + let dir = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(dir.path()); + ctx.set_user_role(UserRole::Power); + let identity = identity_holding(&[bound_to( + auth_key(1, SecurityLevel::CRITICAL), + ContractBounds::ContractGroup { + id: Identifier::new([0x6A; 32]), + }, + )]); + assert_eq!( + token_action_key_status(&ctx, &identity, Identifier::new([0x70; 32])), + SigningKeyStatus::ManualChoiceOnly + ); + } +} diff --git a/src/ui/tokens/set_token_price_screen.rs b/src/ui/tokens/set_token_price_screen.rs index 533ee9ca9..088d6d486 100644 --- a/src/ui/tokens/set_token_price_screen.rs +++ b/src/ui/tokens/set_token_price_screen.rs @@ -5,7 +5,8 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::{Amount, DASH_DECIMAL_PLACES}; use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::user_role::UserRole; +use crate::model::identity_key_usability::SigningScope; +use crate::model::qualified_identity::SigningKeyStatus; use crate::model::wallet::Wallet; use crate::ui::components::ComponentResponse; use crate::ui::components::amount_input::AmountInput; @@ -171,13 +172,7 @@ impl SetTokenPriceScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key: Option<&IdentityPublicKey> = identity_token_info .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); + .token_action_key(identity_token_info.data_contract.contract.id()); let set_error_banner = |msg: &str| super::set_error_banner(app_context, msg); @@ -261,13 +256,14 @@ impl SetTokenPriceScreen { }; // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key).unwrap_or_else( - |e| { - set_error_banner(&e); - None - }, - ); + let selected_wallet = possible_key + .map_or(Ok(None), |key| { + get_selected_wallet(&identity_token_info.identity, None, Some(key)) + }) + .unwrap_or_else(|e| { + set_error_banner(&e); + None + }); Self { identity_token_info: identity_token_info.clone(), @@ -867,6 +863,13 @@ impl ScreenLike for SetTokenPriceScreen { { self.identity_token_info.identity = updated_identity; } + let contract_id = self.identity_token_info.data_contract.contract.id(); + super::pick_key_if_none( + &self.identity_token_info.identity, + &mut self.selected_key, + &mut self.selected_wallet, + |identity| identity.token_action_key(contract_id), + ); } fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { @@ -926,29 +929,24 @@ impl ScreenLike for SetTokenPriceScreen { }); ui.add_space(10.0); - // Check if user has any auth keys - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !self - .identity_token_info - .identity - .identity - .public_keys() - .is_empty() - } else { - !self - .identity_token_info - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; + // Open the form only when the action's key choice picks a key. + let key_status = super::token_action_key_status( + &self.app_context, + &self.identity_token_info.identity, + self.identity_token_info.data_contract.contract.id(), + ); - if !has_keys { + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.show_advanced_options = true; + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { ui.colored_label( Color32::DARK_RED, - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity_token_info.identity.identity_type, - ), + super::token_action_key_message(key_status, &self.identity_token_info.identity), ); ui.add_space(10.0); @@ -1011,12 +1009,19 @@ impl ScreenLike for SetTokenPriceScreen { if self.show_advanced_options { ui.heading("1. Select the key to sign the Set Price transaction"); ui.add_space(10.0); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.app_context, &self.identity_token_info.identity, &mut self.selected_key, TransactionType::TokenAction, + SigningScope::ContractWide { + contract_id: self.identity_token_info.data_contract.contract.id(), + }, ); ui.add_space(10.0); ui.separator(); diff --git a/src/ui/tokens/token_action_screen.rs b/src/ui/tokens/token_action_screen.rs index e31781800..dc35b4a60 100644 --- a/src/ui/tokens/token_action_screen.rs +++ b/src/ui/tokens/token_action_screen.rs @@ -12,6 +12,8 @@ use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::SigningScope; +use crate::model::qualified_identity::SigningKeyStatus; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::component_trait::Component; @@ -34,6 +36,7 @@ use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; use dash_sdk::dpp::data_contract::change_control_rules::authorized_action_takers::AuthorizedActionTakers; use dash_sdk::dpp::data_contract::group::Group; @@ -204,13 +207,7 @@ impl TokenActionScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key = identity_token_info .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + .token_action_key(identity_token_info.data_contract.contract.id()) .cloned(); let takers = A::authorized_takers(&identity_token_info.token_config); @@ -219,12 +216,15 @@ impl TokenActionScreen { super::set_error_banner(app_context, &msg); } - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .unwrap_or_else(|e| { - super::set_error_banner(app_context, &e); - None - }); + let selected_wallet = possible_key + .as_ref() + .map_or(Ok(None), |key| { + get_selected_wallet(&identity_token_info.identity, None, Some(key)) + }) + .unwrap_or_else(|e| { + super::set_error_banner(app_context, &e); + None + }); let action = A::new(&identity_token_info, app_context); @@ -276,17 +276,14 @@ impl TokenActionScreen { } /// Renders the missing-key banner with "Check Keys" / "Add key" shortcuts. - fn render_no_keys(&self, ui: &mut Ui) -> AppAction { + fn render_no_keys(&self, ui: &mut Ui, key_status: SigningKeyStatus) -> AppAction { let mut action = AppAction::None; let identity = &self.common.identity_token_info.identity; let dark_mode = ui.style().visuals.dark_mode; ui.colored_label( DashColors::error_color(dark_mode), - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - identity.identity_type, - ), + super::token_action_key_message(key_status, identity), ); ui.add_space(10.0); @@ -468,6 +465,13 @@ impl ScreenLike for TokenActionScreen { { self.common.identity_token_info.identity = updated; } + let contract_id = self.common.identity_token_info.data_contract.contract.id(); + super::pick_key_if_none( + &self.common.identity_token_info.identity, + &mut self.common.selected_key, + &mut self.common.selected_wallet, + |identity| identity.token_action_key(contract_id), + ); } fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { @@ -504,24 +508,22 @@ impl ScreenLike for TokenActionScreen { return self.show_success_screen(ui); } - let identity = &self.common.identity_token_info.identity; - let has_keys = if self - .common - .app_context - .user_role() - .at_least(UserRole::Developer) - { - !identity.identity.public_keys().is_empty() - } else { - !identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; - - if !has_keys { + let key_status = super::token_action_key_status( + &self.common.app_context, + &self.common.identity_token_info.identity, + self.common.identity_token_info.data_contract.contract.id(), + ); + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.common.show_advanced_options = true; + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { ui.heading(A::PAGE_HEADING); ui.add_space(10.0); - return self.render_no_keys(ui); + return self.render_no_keys(ui, key_status); } let mut inner_action = AppAction::None; @@ -571,12 +573,19 @@ impl ScreenLike for TokenActionScreen { A::VERB )); ui.add_space(10.0); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.common.app_context, &self.common.identity_token_info.identity, &mut self.common.selected_key, TransactionType::TokenAction, + SigningScope::ContractWide { + contract_id: self.common.identity_token_info.data_contract.contract.id(), + }, ); ui.add_space(10.0); ui.separator(); @@ -660,3 +669,51 @@ impl ScreenLike for TokenActionScreen { action } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::qualified_contract::QualifiedContract; + use crate::ui::tokens::mint_tokens_screen::MintTokensScreen; + use crate::ui::tokens::tests::{ + identity_with_expired_key, store_identity_with_usable_wallet_key, + }; + use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use dash_sdk::platform::Identifier; + + /// Regression: the action's key was picked only when the form opened, so + /// once the identity's key became usable a refresh opened the form with + /// no key to sign. A refresh picks it then, with its wallet. + #[test] + fn a_refresh_picks_the_token_action_key_that_became_usable() { + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(dir.path()); + let info = IdentityTokenInfo { + token_id: Identifier::from([4; 32]), + token_alias: "Test token".into(), + identity: identity_with_expired_key(), + data_contract: QualifiedContract { + contract: context.dpns_contract().as_ref().clone(), + alias: None, + }, + token_config: TokenConfigurationV0::default_most_restrictive().into(), + token_position: 0, + }; + let mut screen = MintTokensScreen::new(info, &context); + assert!(screen.common.selected_key.is_none()); + let seed_hash = store_identity_with_usable_wallet_key(&context); + + screen.refresh(); + assert_eq!( + screen.common.selected_key.as_ref().map(|key| key.id()), + Some(1) + ); + let wallet = screen + .common + .selected_wallet + .as_ref() + .expect("the key's wallet"); + assert_eq!(wallet.read().expect("wallet").seed_hash(), seed_hash); + } +} diff --git a/src/ui/tokens/tokens_screen/token_creator.rs b/src/ui/tokens/tokens_screen/token_creator.rs index a820634b6..1ed7bc055 100644 --- a/src/ui/tokens/tokens_screen/token_creator.rs +++ b/src/ui/tokens/tokens_screen/token_creator.rs @@ -19,6 +19,7 @@ use crate::backend_task::BackendTask; use crate::backend_task::error::TaskError; use crate::backend_task::tokens::TokenTask; use crate::context::feature_gate::FeatureGate; +use crate::model::identity_key_usability::SigningScope; use crate::model::token::validate_contract_keyword; use crate::model::token::{ distribution_rules_with_once_per_identity, once_per_identity_amount_in_rules, @@ -255,6 +256,7 @@ impl TokensScreen { &mut self.selected_identity, &mut self.selected_key, TransactionType::RegisterContract, + SigningScope::NonBatch, ); let after_id = self .selected_identity diff --git a/src/ui/tokens/transfer_tokens_screen.rs b/src/ui/tokens/transfer_tokens_screen.rs index 58487e2f0..9a6446686 100644 --- a/src/ui/tokens/transfer_tokens_screen.rs +++ b/src/ui/tokens/transfer_tokens_screen.rs @@ -4,7 +4,8 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; -use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::identity_key_usability::SigningScope; +use crate::model::qualified_identity::{QualifiedIdentity, SigningKeyStatus}; use crate::model::user_role::UserRole; use crate::model::wallet::Wallet; use crate::ui::components::amount_input::AmountInput; @@ -95,17 +96,13 @@ impl TransferTokensScreen { let max_amount = Amount::from(&identity_token_balance); let selected_key: Option = identity.as_ref().and_then(|id| { - id.identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + id.single_token_transition_key(identity_token_balance.data_contract_id) .cloned() }); let selected_wallet = identity.as_ref().and_then(|id| { - get_selected_wallet(id, None, selected_key.as_ref()) + selected_key + .as_ref() + .map_or(Ok(None), |key| get_selected_wallet(id, None, Some(key))) .or_show_error(app_context.egui_ctx()) .unwrap_or(None) }); @@ -359,6 +356,15 @@ impl ScreenLike for TransferTokensScreen { self.identity = Some(refreshed); } } + if let Some(identity) = &self.identity { + let contract_id = self.identity_token_balance.data_contract_id; + super::pick_key_if_none( + identity, + &mut self.selected_key, + &mut self.selected_wallet, + |identity| identity.single_token_transition_key(contract_id), + ); + } if let Some(current_id) = self.identity.as_ref().map(|id| id.identity.id()) { match self.app_context.identity_token_balances() { Ok(token_balances) => { @@ -444,33 +450,47 @@ impl ScreenLike for TransferTokensScreen { }); ui.add_space(10.0); - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !identity.identity.public_keys().is_empty() + let key_status = if self.app_context.user_role().at_least(UserRole::Developer) { + if identity.identity.public_keys().is_empty() { + SigningKeyStatus::NoneHeld + } else { + SigningKeyStatus::Usable + } } else { - !identity - .available_authentication_keys_with_critical_security_level() - .is_empty() + identity.single_token_transition_key_status( + self.identity_token_balance.data_contract_id, + ) }; - if !has_keys { + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.show_advanced_options = true; + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { let identity_type = identity.identity_type; // Extract key data before releasing the borrow (needed for click handlers). - let key = identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + let key = [Purpose::AUTHENTICATION, Purpose::TRANSFER] + .into_iter() + .find_map(|purpose| { + identity.identity.get_first_public_key_matching( + purpose, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ) + }) .cloned(); - ui.colored_label( - DashColors::error_color(dark_mode), + let message = if key_status == SigningKeyStatus::HeldButUnusable { + "No key on this device can sign this token transfer. The identity's keys are disabled, expired, reserved for another app or token, or not available on this device. Add a new key, or load the wallet that holds one, then try again.".to_string() + } else { format!( - "You do not have any authentication keys with CRITICAL security level loaded for this {} identity.", - identity_type - ), - ); + "You do not have a CRITICAL authentication or transfer key loaded for this {identity_type} identity." + ) + }; + ui.colored_label(DashColors::error_color(dark_mode), message); ui.add_space(10.0); if let Some(key) = key { @@ -524,12 +544,19 @@ impl ScreenLike for TransferTokensScreen { ui.add_space(10.0); // Reborrow identity as ref for add_key_chooser; selected_key is &mut self. let identity = self.identity.as_ref().expect("checked above"); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); + } add_key_chooser( ui, &self.app_context, identity, &mut self.selected_key, TransactionType::TokenTransfer, + SigningScope::ContractWide { + contract_id: self.identity_token_balance.data_contract_id, + }, ); ui.add_space(10.0); ui.separator(); diff --git a/src/ui/tokens/update_token_config.rs b/src/ui/tokens/update_token_config.rs index 1190f5e98..cc087c567 100644 --- a/src/ui/tokens/update_token_config.rs +++ b/src/ui/tokens/update_token_config.rs @@ -4,7 +4,9 @@ use crate::backend_task::tokens::TokenTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; +use crate::model::identity_key_usability::SigningScope; use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::qualified_identity::SigningKeyStatus; use crate::model::token::{ TokenConfigInputError, parse_max_supply_input, parse_new_tokens_destination_input, token_change_item_is_supported, @@ -29,6 +31,7 @@ use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::tokens::validate_signing_key; use crate::ui::{MessageType, Screen, ScreenLike}; use dash_sdk::dpp::data_contract::GroupContractPosition; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::accessors::v1::DataContractV1Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration::accessors::v0::TokenConfigurationV0Getters; use dash_sdk::dpp::data_contract::associated_token::token_configuration_convention::TokenConfigurationConvention; @@ -90,13 +93,7 @@ impl UpdateTokenConfigScreen { pub fn new(identity_token_info: IdentityTokenInfo, app_context: &Arc) -> Self { let possible_key = identity_token_info .identity - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ) + .token_action_key(identity_token_info.data_contract.contract.id()) .cloned(); // Initialize with no group - will be set when user selects a change item @@ -106,10 +103,13 @@ impl UpdateTokenConfigScreen { let is_unilateral_group_member = false; // Attempt to get an unlocked wallet reference - let selected_wallet = - get_selected_wallet(&identity_token_info.identity, None, possible_key.as_ref()) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + let selected_wallet = possible_key + .as_ref() + .map_or(Ok(None), |key| { + get_selected_wallet(&identity_token_info.identity, None, Some(key)) + }) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None); Self { identity_token_info: identity_token_info.clone(), @@ -974,96 +974,103 @@ impl ScreenLike for UpdateTokenConfigScreen { }); ui.add_space(10.0); - // Check if user has any auth keys - let has_keys = if self.app_context.user_role().at_least(UserRole::Developer) { - !self.identity.identity.public_keys().is_empty() - } else { - !self - .identity - .available_authentication_keys_with_critical_security_level() - .is_empty() - }; - - if !has_keys { - ui.colored_label( - Color32::DARK_RED, - format!( - "No authentication keys with CRITICAL security level found for this {} identity.", - self.identity.identity_type, - ), + // Open the form only when the action's key choice picks a key. + let key_status = super::token_action_key_status( + &self.app_context, + &self.identity, + self.identity_token_info.data_contract.contract.id(), ); - ui.add_space(10.0); - // Show "Add key" or "Check keys" option - let first_key = self.identity.identity.get_first_public_key_matching( - Purpose::AUTHENTICATION, - HashSet::from([SecurityLevel::CRITICAL]), - KeyType::all_key_types().into(), - false, - ); + if key_status == SigningKeyStatus::ManualChoiceOnly { + // Only a contract-group key can sign it: the user picks it below. + self.show_advanced_options = true; + } + if !matches!( + key_status, + SigningKeyStatus::Usable | SigningKeyStatus::ManualChoiceOnly + ) { + ui.colored_label( + Color32::DARK_RED, + super::token_action_key_message(key_status, &self.identity), + ); + ui.add_space(10.0); - if let Some(key) = first_key { - if ui.button("Check Keys").clicked() { - action |= AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + // Show "Add key" or "Check keys" option + let first_key = self.identity.identity.get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::CRITICAL]), + KeyType::all_key_types().into(), + false, + ); + + if let Some(key) = first_key { + if ui.button("Check Keys").clicked() { + action |= + AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + None, + &self.app_context, + ))); + } + ui.add_space(5.0); + } + + if ui.button("Add key").clicked() { + action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( self.identity.clone(), - key.clone(), - None, &self.app_context, ))); } - ui.add_space(5.0); - } - - if ui.button("Add key").clicked() { - action |= AppAction::AddScreen(Screen::AddKeyScreen(AddKeyScreen::new( - self.identity.clone(), - &self.app_context, - ))); - } - } else { - // Possibly handle locked wallet scenario (similar to TransferTokens) - if let Some(wallet) = &self.selected_wallet { - if !self.wallet_open_attempted { - if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { - MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) - .disable_auto_dismiss(); + } else { + // Possibly handle locked wallet scenario (similar to TransferTokens) + if let Some(wallet) = &self.selected_wallet { + if !self.wallet_open_attempted { + if let Err(e) = try_open_wallet_no_password(&self.app_context, wallet) { + MessageBanner::set_global(ui.ctx(), &e, MessageType::Error) + .disable_auto_dismiss(); + } + self.wallet_open_attempted = true; + } + if wallet_needs_unlock(wallet) { + ui.add_space(10.0); + ui.colored_label( + egui::Color32::from_rgb(200, 150, 50), + "Wallet is locked. Please unlock to continue.", + ); + ui.add_space(8.0); + if ui.button("Unlock Wallet").clicked() { + self.wallet_unlock_popup.open(); + } + return; } - self.wallet_open_attempted = true; } - if wallet_needs_unlock(wallet) { + + // Key selection (only in advanced mode) + if self.show_advanced_options { + ui.heading("1. Select the key to sign the transaction with"); ui.add_space(10.0); - ui.colored_label( - egui::Color32::from_rgb(200, 150, 50), - "Wallet is locked. Please unlock to continue.", - ); - ui.add_space(8.0); - if ui.button("Unlock Wallet").clicked() { - self.wallet_unlock_popup.open(); + if key_status == SigningKeyStatus::ManualChoiceOnly { + ui.label(super::CONTRACT_GROUP_KEY_NOTE); + ui.add_space(5.0); } - return; + add_key_chooser( + ui, + &self.app_context, + &self.identity, + &mut self.signing_key, + TransactionType::TokenAction, + SigningScope::ContractWide { + contract_id: self.identity_token_info.data_contract.contract.id(), + }, + ); + ui.add_space(10.0); + ui.separator(); } - } - - - // Key selection (only in advanced mode) - if self.show_advanced_options { - ui.heading("1. Select the key to sign the transaction with"); - ui.add_space(10.0); - add_key_chooser( - ui, - &self.app_context, - &self.identity, - &mut self.signing_key, - TransactionType::TokenAction, - ); ui.add_space(10.0); - ui.separator(); - } - ui.add_space(10.0); - - action |= self.render_token_config_updater(ui); - } + action |= self.render_token_config_updater(ui); + } }); // end of ScrollArea }); diff --git a/src/ui/wallets/send_screen.rs b/src/ui/wallets/send_screen.rs index 02fbf1bf1..b1d9d6152 100644 --- a/src/ui/wallets/send_screen.rs +++ b/src/ui/wallets/send_screen.rs @@ -2385,7 +2385,9 @@ impl WalletSendScreen { // withdrawal no key can sign before the confirmation. qualified_identity .resolve_withdrawal_signing_key(None) - .map_err(|_| TaskError::NoUsableWithdrawalKey.to_string())?; + .map_err(|_| { + TaskError::no_usable_withdrawal_key(qualified_identity.identity_type).to_string() + })?; let core_address = self .validated_destination diff --git a/src/wallet_backend/auto_accept.rs b/src/wallet_backend/auto_accept.rs new file mode 100644 index 000000000..d70e6d67f --- /dev/null +++ b/src/wallet_backend/auto_accept.rs @@ -0,0 +1,299 @@ +//! DashPay contact QR codes (DIP-15 auto-accept) through upstream +//! platform-wallet. +//! +//! The owner's auto-accept key is derived from the identity's wallet seed at +//! `m/9'/coin'/16'/expiry'`. The seed is read only through the secret seam, +//! for one operation, and only [`AutoAcceptKeyExport`] sees it: a +//! [`ContactCryptoProvider`] that exports auto-accept keys and refuses every +//! other request. + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPubKey}; +use dash_sdk::dpp::secp256k1::{PublicKey, SecretKey}; +use platform_wallet::error::PlatformWalletError; +use platform_wallet::wallet::identity::crypto::auto_accept::auto_accept_derivation_path; +use platform_wallet::{ContactCryptoProvider, ContactInfoOpened, ContactInfoSealed}; +use zeroize::Zeroizing; + +use super::{SecretScope, WalletBackend}; +use crate::backend_task::error::TaskError; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::WalletSeedHash; + +impl WalletBackend { + /// The contact QR code of `identity` (`dash:?du=&dapk=`), + /// derived from the seed of `seed_hash`, the identity's owning wallet + /// (`AppContext::contact_qr_wallet_seed_hash`), and + /// built by upstream's `DashPayView::build_auto_accept_qr`: valid for + /// upstream's auto-accept lifetime, naming the identity's first cached + /// username (or one upstream finds on Platform). + /// + /// # Errors + /// + /// The wallet lookup's error when that wallet is not loaded, the seam's + /// error when the seed cannot be read, and + /// [`TaskError::ContactQrBuildFailed`] when upstream cannot build the code. + pub(crate) async fn dashpay_build_auto_accept_qr( + &self, + identity: &QualifiedIdentity, + seed_hash: WalletSeedHash, + ) -> Result { + let wallet = self.resolve_wallet(&seed_hash).await?; + let owner = identity.identity.id(); + // Upstream resolves a name on Platform when given none. + let username = identity + .dpns_names + .first() + .map(|name| { + let name = name.name.trim(); + name.strip_suffix(".dash").unwrap_or(name).to_string() + }) + .unwrap_or_default(); + let network = identity.network; + let identity_wallet = wallet.identity(); + let dashpay = identity_wallet.dashpay(); + + self.secret_access() + .with_secret_session(&SecretScope::HdSeed { seed_hash }, async |session| { + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let provider = AutoAcceptKeyExport::new(seed, network); + dashpay + .build_auto_accept_qr(&owner, &username, &provider) + .await + .map_err(|source| TaskError::ContactQrBuildFailed { + source: Arc::new(source), + }) + }) + .await + } +} + +/// Exports the DIP-15 auto-accept private key of one wallet seed, and nothing +/// else: a request for any other path, or for another network's path, is +/// refused. +/// +/// Borrows the seed for one secret session; it never copies it out. +pub(crate) struct AutoAcceptKeyExport<'a> { + seed: &'a [u8; 64], + network: Network, +} + +impl<'a> AutoAcceptKeyExport<'a> { + pub(crate) fn new(seed: &'a [u8; 64], network: Network) -> Self { + Self { seed, network } + } +} + +/// The provider serves auto-accept key exports only. +fn not_auto_accept() -> PlatformWalletError { + PlatformWalletError::InvalidParameter( + "this crypto provider exports DashPay auto-accept keys only".to_string(), + ) +} + +#[async_trait::async_trait] +impl ContactCryptoProvider for AutoAcceptKeyExport<'_> { + async fn receiving_xpub( + &self, + _path: &DerivationPath, + ) -> Result { + Err(not_auto_accept()) + } + + async fn ecdh_shared_secret( + &self, + _path: &DerivationPath, + _peer: &PublicKey, + ) -> Result, PlatformWalletError> { + Err(not_auto_accept()) + } + + async fn export_auto_accept_private_key( + &self, + path: &DerivationPath, + ) -> Result { + // Only `m/9'/coin'/16'/expiry'` of this network: the hardened leaf is + // the expiry, and the whole path must be the one upstream builds for it. + let expiry = match path.into_iter().last() { + Some(ChildNumber::Hardened { index }) => *index, + _ => return Err(not_auto_accept()), + }; + if auto_accept_derivation_path(self.network, expiry)? != *path { + return Err(not_auto_accept()); + } + let extended = path + .derive_priv_ecdsa_for_master_seed(self.seed, self.network) + .map_err(|error| PlatformWalletError::KeyDerivation(error.to_string()))?; + let secret = Zeroizing::new(*extended.private_key.to_bytes()); + SecretKey::from_secret_bytes(*secret) + .map_err(|error| PlatformWalletError::KeyDerivation(error.to_string())) + } + + async fn account_reference( + &self, + _path: &DerivationPath, + _compact_xpub: &[u8], + _account_index: u32, + _version: u32, + ) -> Result { + Err(not_auto_accept()) + } + + async fn unmask_account_reference( + &self, + _path: &DerivationPath, + _compact_xpub: &[u8], + _account_reference: u32, + ) -> Result<(u32, u32), PlatformWalletError> { + Err(not_auto_accept()) + } + + async fn contact_info_seal( + &self, + _root_path: &DerivationPath, + _derivation_index: u32, + _contact_id: &[u8; 32], + _private_data_plaintext: &[u8], + _private_data_iv: &[u8; 16], + ) -> Result { + Err(not_auto_accept()) + } + + async fn contact_info_open( + &self, + _root_path: &DerivationPath, + _derivation_index: u32, + _enc_to_user_id: &[u8; 32], + _private_data_blob: &[u8], + ) -> Result { + Err(not_auto_accept()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::dashpay::ContactQr; + use dash_sdk::dpp::key_wallet::wallet::Wallet; + use dash_sdk::dpp::key_wallet::wallet::initialization::WalletAccountCreationOptions; + use dash_sdk::platform::Identifier; + use platform_wallet::wallet::identity::crypto::auto_accept::{ + derive_auto_accept_private_key, encode_auto_accept_key_blob, encode_dashpay_contact_uri, + verify_auto_accept_proof, + }; + + const NETWORK: Network = Network::Testnet; + const EXPIRY: u32 = 1_900_000_000; + + /// Regression: DET derived the auto-accept key from an identity + /// ENCRYPTION key's private bytes, so no other DashPay client could + /// verify its proofs. The exported key is the one upstream derives from + /// the wallet seed. + #[tokio::test] + async fn the_exported_key_is_the_wallets_dip15_auto_accept_key() { + let seed = Zeroizing::new(rand::random::<[u8; 64]>()); + let wallet = Wallet::from_seed_bytes(*seed, NETWORK, WalletAccountCreationOptions::None) + .expect("a test wallet"); + let provider = AutoAcceptKeyExport::new(&seed, NETWORK); + + let path = auto_accept_derivation_path(NETWORK, EXPIRY).expect("a path"); + let exported = provider + .export_auto_accept_private_key(&path) + .await + .expect("an auto-accept path"); + assert_eq!( + exported, + derive_auto_accept_private_key(&wallet, NETWORK, EXPIRY).expect("upstream key") + ); + } + + /// Interop end to end: the code the owner's provider feeds upstream's + /// QR codec is read by DET's scanner, whose proof upstream verifies + /// against the owner's wallet. + #[tokio::test] + async fn a_scanned_code_yields_a_proof_the_owner_verifies() { + let seed = Zeroizing::new(rand::random::<[u8; 64]>()); + let wallet = Wallet::from_seed_bytes(*seed, NETWORK, WalletAccountCreationOptions::None) + .expect("a test wallet"); + let provider = AutoAcceptKeyExport::new(&seed, NETWORK); + let key = provider + .export_auto_accept_private_key( + &auto_accept_derivation_path(NETWORK, EXPIRY).expect("a path"), + ) + .await + .expect("a key"); + let uri = encode_dashpay_contact_uri("alice", &encode_auto_accept_key_blob(&key, EXPIRY)); + + let now = u64::from(EXPIRY) - 60; + let (sender, owner) = (Identifier::new([1; 32]), Identifier::new([2; 32])); + let proof = ContactQr::parse(&uri, now) + .expect("valid") + .sign_proof(&sender, &owner, 3, now) + .expect("a proof"); + assert!( + verify_auto_accept_proof(&wallet, NETWORK, &proof, &sender, &owner, 3) + .expect("verifiable") + ); + } + + /// Only an auto-accept path on the provider's network is exported; every + /// other request is refused. + #[tokio::test] + async fn everything_but_an_auto_accept_key_is_refused() { + let seed = Zeroizing::new(rand::random::<[u8; 64]>()); + let provider = AutoAcceptKeyExport::new(&seed, NETWORK); + + let mainnet = auto_accept_derivation_path(Network::Mainnet, EXPIRY).expect("a path"); + let identity_key: DerivationPath = vec![ + ChildNumber::from_hardened_idx(9).expect("valid"), + ChildNumber::from_hardened_idx(1).expect("valid"), + ChildNumber::from_hardened_idx(5).expect("valid"), + ChildNumber::from_hardened_idx(0).expect("valid"), + ] + .into(); + let mut unhardened = auto_accept_derivation_path(NETWORK, EXPIRY) + .expect("a path") + .into_iter() + .copied() + .collect::>(); + unhardened.pop(); + unhardened.push(ChildNumber::from_normal_idx(EXPIRY & 0x7FFF_FFFF).expect("valid")); + for path in [mainnet, identity_key, DerivationPath::from(unhardened)] { + assert!( + provider + .export_auto_accept_private_key(&path) + .await + .is_err(), + "{path}" + ); + } + + let path = auto_accept_derivation_path(NETWORK, EXPIRY).expect("a path"); + let peer = PublicKey::from_secret_key(&crate::test_support::keys::secret_key()); + assert!(provider.receiving_xpub(&path).await.is_err()); + assert!(provider.ecdh_shared_secret(&path, &peer).await.is_err()); + assert!(provider.account_reference(&path, &[], 0, 0).await.is_err()); + assert!( + provider + .unmask_account_reference(&path, &[], 0) + .await + .is_err() + ); + assert!( + provider + .contact_info_seal(&path, 0, &[0; 32], &[], &[0; 16]) + .await + .is_err() + ); + assert!( + provider + .contact_info_open(&path, 0, &[0; 32], &[]) + .await + .is_err() + ); + } +} diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 92d479605..49cd94ac5 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -23,6 +23,7 @@ pub mod auth_pubkey_cache; #[cfg(not(any(test, feature = "bench")))] pub(crate) mod auth_pubkey_cache; +mod auto_accept; mod avatar_cache; mod contact_profile_cache; mod coordinator_gate; diff --git a/tests/backend-e2e/measure_dpns_registration.rs b/tests/backend-e2e/measure_dpns_registration.rs index 6a944e268..947841e6e 100644 --- a/tests/backend-e2e/measure_dpns_registration.rs +++ b/tests/backend-e2e/measure_dpns_registration.rs @@ -23,7 +23,6 @@ use crate::register_dpns::{ use dash_evo_tool::context::AppContext; use dash_evo_tool::model::fee_estimation::contest_fee_credits; use dash_evo_tool::model::qualified_identity::QualifiedIdentity; -use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; use dash_sdk::platform::dpns_usernames::{ @@ -161,11 +160,8 @@ async fn register_and_log( let dpns = load_system_data_contract(SystemDataContract::DPNS, app_context.sdk_platform_version()) .expect("the DPNS contract should load"); - let preorder = dpns - .document_type_for_name("preorder") - .expect("the DPNS contract should define preorder"); let signing_key = identity - .document_signing_key(&preorder) + .username_registration_key(&dpns) .cloned() .expect("the identity should hold a key that signs name documents"); diff --git a/tests/kittest/identity_usernames.rs b/tests/kittest/identity_usernames.rs index 6c0d2759e..d9c2c7197 100644 --- a/tests/kittest/identity_usernames.rs +++ b/tests/kittest/identity_usernames.rs @@ -54,6 +54,15 @@ pub(crate) fn seed_username_identity( key.set_id(1); key.set_purpose(Purpose::AUTHENTICATION); key.set_security_level(SecurityLevel::HIGH); + // Usernames are registered with ECDSA keys only; `random_key` may pick + // any key type. + key.set_key_type(dash_sdk::dpp::identity::KeyType::ECDSA_SECP256K1); + key.set_data( + std::iter::once(2) + .chain(std::iter::repeat_n(byte, 32)) + .collect::>() + .into(), + ); let mut identity = Identity::new_with_id_and_keys( Identifier::from([byte; 32]), BTreeMap::from([(key.id(), key.clone())]), diff --git a/tests/kittest/transfer_screen.rs b/tests/kittest/transfer_screen.rs index 7723c0abc..94734258e 100644 --- a/tests/kittest/transfer_screen.rs +++ b/tests/kittest/transfer_screen.rs @@ -46,6 +46,67 @@ fn identity_without_transfer_keys() -> QualifiedIdentity { } } +/// An identity holding only an expired transfer key: it has a key, but no +/// key that can sign a transfer. +fn identity_with_only_an_expired_transfer_key() -> QualifiedIdentity { + use dash_evo_tool::model::qualified_identity::PrivateKeyTarget; + use dash_evo_tool::model::qualified_identity::encrypted_key_storage::PrivateKeyData; + use dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use dash_sdk::dpp::identity::accessors::IdentitySettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::identity::{IdentityPublicKey, Purpose}; + + let (mut transfer, _) = IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + 1, + None, + PlatformVersion::latest(), + ) + .expect("a random ECDSA key"); + transfer.set_purpose(Purpose::TRANSFER); + let expired = transfer.with_limits(None, Some(1)); + + let mut qi = identity_without_transfer_keys(); + qi.identity + .set_public_keys(BTreeMap::from([(1, expired.clone())])); + qi.private_keys = KeyStorage::from(BTreeMap::from([( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, 1), + ( + QualifiedIdentityPublicKey::from(expired), + PrivateKeyData::Clear(rand::random()), + ), + )])); + qi +} + +/// The gate opens only for a key the form can sign with: an identity holding +/// only an expired transfer key is told its key cannot sign, not handed a +/// form with no key selected. +#[test] +fn an_expired_transfer_key_closes_the_form_with_its_own_message() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + let mut screen = + TransferScreen::new(identity_with_only_an_expired_transfer_key(), &app_context); + let mut harness = Harness::builder() + .with_size(egui::vec2(1280.0, 900.0)) + .build_ui(move |ui| { + screen.ui(ui); + }); + harness.run_steps(3); + + assert!( + harness + .query_by_label_contains("No transfer key on this device can sign") + .is_some(), + "an expired-key-only identity must be told its key cannot sign" + ); + assert!( + harness.query_by_label("Manage keys").is_some(), + "the closed gate still offers the keys list" + ); + }); +} + /// The no-key branch signposts the keys list instead of dead-ending on "Add /// key", and clicking it opens that list. #[test] diff --git a/tests/kittest/withdraw_screen.rs b/tests/kittest/withdraw_screen.rs index bfa898172..d2570a3df 100644 --- a/tests/kittest/withdraw_screen.rs +++ b/tests/kittest/withdraw_screen.rs @@ -6,8 +6,8 @@ //! on-chain TRANSFER key with no local private material, e.g. a loaded //! masternode identity with the payout field left blank) could get silently //! pre-selected even though the signer can never use it. The fix -//! (`identity.default_withdrawal_key()`, TRANSFER-preferred / OWNER-fallback, -//! private-key-backed only) is unit-tested at the model layer in +//! (`identity.withdrawal_signing_key()`, TRANSFER-preferred / OWNER-fallback, +//! signable keys only) is unit-tested at the model layer in //! `src/model/qualified_identity/mod.rs::withdrawal_key_tests`. This file //! verifies the fix holds when `WithdrawalScreen` is actually constructed and //! rendered — the layer the model-only tests never touched. @@ -37,7 +37,7 @@ use dash_evo_tool::ui::identity::withdraw_screen::WithdrawalScreen; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, }; -use dash_sdk::dpp::identity::{Identity, KeyID, KeyType, Purpose, SecurityLevel}; +use dash_sdk::dpp::identity::{Identity, KeyID, KeyType, Purpose}; use dash_sdk::dpp::platform_value::BinaryData; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::platform::{Identifier, IdentityPublicKey}; @@ -50,10 +50,13 @@ use std::sync::Arc; /// `model/qualified_identity/mod.rs::withdrawal_key_tests` so the two test /// suites stay in lockstep on fixture shape. fn key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { - let mut k = IdentityPublicKey::random_key(id, Some(id as u64), PlatformVersion::latest()); - k.set_id(id); + let (mut k, _) = IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + id, + Some(id as u64), + PlatformVersion::latest(), + ) + .expect("a random ECDSA key"); k.set_purpose(purpose); - k.set_security_level(SecurityLevel::CRITICAL); k } @@ -150,7 +153,7 @@ fn fresh_context() -> (tokio::runtime::Runtime, Arc) { /// Ghost-key repro: an identity whose only on-chain key is a `Purpose::TRANSFER` /// key with no local private material (like a loaded masternode identity with /// the payout field left blank). The withdraw form must never render for it — -/// `available_withdrawal_keys()` (private-key-backed only) is empty, so the +/// `withdrawal_signing_key()` (signable keys only) picks nothing, so the /// screen shows the "no eligible key" empty state, not a Confirm-enabled form. #[test] fn ghost_transfer_key_shows_no_keys_empty_state_not_a_form() { @@ -188,7 +191,41 @@ fn ghost_transfer_key_shows_no_keys_empty_state_not_a_form() { }); } -/// Regression lock for a bug this fix (default_withdrawal_key) briefly +/// An identity whose only withdrawal key has expired holds a key but cannot +/// sign with it: the gate stays closed, as the backend would refuse to pick +/// that key, and says why instead of claiming no key is loaded. +#[test] +fn an_expired_withdrawal_key_closes_the_form_with_its_own_message() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_context(); + + let expired = key(1, Purpose::TRANSFER).with_limits(None, Some(1)); + let identity = build_identity( + &app_context, + IdentityType::User, + vec![expired.clone()], + vec![expired], + ); + + let screen = WithdrawalScreen::new(identity, &app_context); + let harness = mount_withdrawal_screen(screen); + + assert!( + harness + .query_by_label_contains("No key on this device can approve a withdrawal") + .is_some(), + "an expired-key-only identity must be told its key cannot sign" + ); + assert!( + harness + .query_by_label_contains("Amount to withdraw") + .is_none(), + "the withdraw form must not render without a key that can sign" + ); + }); +} + +/// Regression lock for a bug this fix (then `default_withdrawal_key`) briefly /// introduced and a follow-up fix (2edbc18e, "skip wallet resolution when no /// signable key exists") closed: constructing `WithdrawalScreen` for a /// ghost-key-only identity used to leak a raw, technical error banner @@ -320,7 +357,7 @@ fn private_backed_transfer_key_is_selected_and_rendered_in_combo() { } /// Owner fallback: a masternode-type identity with only a private-key-backed -/// OWNER key, no TRANSFER key at all. `default_withdrawal_key()` must fall +/// OWNER key, no TRANSFER key at all. `withdrawal_signing_key()` must fall /// back to OWNER, and the rendered ComboBox must reflect that exact key. #[test] fn owner_key_fallback_is_selected_and_rendered_in_combo_when_no_transfer_key() { @@ -380,7 +417,7 @@ fn owner_key_destination_is_locked_at_every_role() { MessageBanner::clear_all_global(app_context.egui_ctx()); // Only the OWNER key is signable locally; the payout key is on-chain - // only, so `default_withdrawal_key()` falls back to OWNER while + // only, so `withdrawal_signing_key()` falls back to OWNER while // `masternode_payout_address()` still resolves the forced destination. let owner = key(1, Purpose::OWNER); let payout = payout_key(2, [0x11; 20]);