From f28f868c981f7c5a3872b4330d29330c0cb403ca Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 15:22:03 -0500 Subject: [PATCH 1/2] test(drive-abci): cover a contract-level bound encryption key under MultipleReferenceToLatest Registers a MEDIUM ENCRYPTION key with SingleContract bounds against a contract whose config opts in with requiresIdentityEncryptionBoundedKey = 2. The existing test only covers the Unique (0) mode. This one fails on v4.2-dev: Drive inserts the current-key sibling reference beside the purpose subtree instead of inside it, grovedb reports a missing reference, and the transition ends as an InternalError. Co-Authored-By: Claude Fable 5.1 --- .../state_transitions/identity_update/mod.rs | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs index 04e7511deed..cb84baa99e9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs @@ -1976,6 +1976,272 @@ mod tests { ); } + #[tokio::test] + async fn test_identity_update_adding_contract_bound_key_multiple_reference_to_latest() { + use crate::execution::validation::state_transition::tests::{ + register_contract_from_bytes, IdentityTestInfo, + }; + + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + + let platform_version = PlatformVersion::latest(); + + let mut platform = TestPlatformBuilder::new() + .with_config(platform_config) + .build_with_mock_rpc() + .set_genesis_state(); + + // { + // "$formatVersion": "1", + // "id": "5pkMhyeaFjJfVMkFhLtJdDp2ofx6iqt7i9k6ckkHBwbs", + // "config": { + // "$formatVersion": "1", + // "canBeDeleted": false, + // "readonly": false, + // "keepsHistory": false, + // "documentsKeepHistoryContractDefault": false, + // "documentsMutableContractDefault": true, + // "documentsCanBeDeletedContractDefault": true, + // "requiresIdentityEncryptionBoundedKey": 0, + // "requiresIdentityDecryptionBoundedKey": 0, + // "sizedIntegerTypes": true + // }, + // "version": 1, + // "ownerId": "DicUmimv71VqxNBzZHXb887RgssSEjjx7DyLfxrt8q1X", + // "schemaDefs": null, + // "documentSchemas": { + // "preorder": { + // "documentsMutable": false, + // "canBeDeleted": true, + // "type": "object", + // "indices": [ + // { + // "name": "saltedHash", + // "properties": [ + // { + // "saltedDomainHash": "asc" + // } + // ], + // "unique": true + // } + // ], + // "properties": { + // "saltedDomainHash": { + // "type": "array", + // "byteArray": true, + // "minItems": 32, + // "maxItems": 32, + // "position": 0, + // "description": "Double sha-256 of the concatenation of a 32 byte random salt and a normalized domain name" + // } + // }, + // "required": [ + // "saltedDomainHash" + // ], + // "additionalProperties": false, + // "$comment": "Preorder documents are immutable: modification and deletion are restricted" + // } + // }, + // "createdAt": 1749816974718, + // "updatedAt": null, + // "createdAtBlockHeight": 159130, + // "updatedAtBlockHeight": null, + // "createdAtEpoch": 7906, + // "updatedAtEpoch": null, + // "groups": {}, + // "tokens": {}, + // "keywords": [], + // "description": null + // } + let contract_bytes = hex::decode("0147aa11d517710d509edaf84bb54902394dcb8f6cc68775138d1cdd8334600d2e01000000000101010001000101bcf52c1c5d57d2e21530c5d03ef4c6e7b39a91da7c444fdb17e0a7746b6285860001087072656f7264657216081210646f63756d656e74734d757461626c651300120c63616e426544656c65746564130012047479706512066f626a6563741207696e64696365731501160312046e616d65120a73616c74656448617368120a70726f7065727469657315011601121073616c746564446f6d61696e4861736812036173631206756e697175651301120a70726f706572746965731601121073616c746564446f6d61696e486173681606120474797065120561727261791209627974654172726179130112086d696e4974656d73022012086d61784974656d7302201208706f736974696f6e0200120b6465736372697074696f6e1259446f75626c65207368612d323536206f662074686520636f6e636174656e6174696f6e206f66206120333220627974652072616e646f6d2073616c7420616e642061206e6f726d616c697a656420646f6d61696e206e616d65120872657175697265641501121073616c746564446f6d61696e4861736812146164646974696f6e616c50726f706572746965731300120824636f6d6d656e74124a5072656f7264657220646f63756d656e74732061726520696d6d757461626c653a206d6f64696669636174696f6e20616e642064656c6574696f6e20617265207265737472696374656401fd0000019769381d7e0001fc00026d9a0001fb1ee20000000000").expect("expected to decode contract bytes"); + + let (identity, signer, critical_key, master_key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(5.0)); + + let platform_state = platform.state.load(); + + // Same contract, but opting bound ENCRYPTION and DECRYPTION keys in with + // `MultipleReferenceToLatest` (2), the mode the DashPay contract and every + // contract published from the JS SDK use, instead of `Unique` (0). + let contract_bytes = { + use dpp::data_contract::config::v0::DataContractConfigSettersV0; + use dpp::data_contract::storage_requirements::keys_for_document_type::StorageKeyRequirements; + use dpp::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, + }; + let mut contract = dpp::data_contract::DataContract::versioned_deserialize_untrusted( + &contract_bytes, + false, + platform_version, + ) + .expect("expected to deserialize data contract"); + contract + .config_mut() + .set_requires_identity_encryption_bounded_key(Some( + StorageKeyRequirements::MultipleReferenceToLatest, + )); + contract + .config_mut() + .set_requires_identity_decryption_bounded_key(Some( + StorageKeyRequirements::MultipleReferenceToLatest, + )); + contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("expected to serialize data contract") + }; + + // Register the contract + let data_contract = register_contract_from_bytes( + &mut platform, + &platform_state, + contract_bytes, + IdentityTestInfo::Given { + identity: &identity, + signer: &signer, + public_key: &critical_key, + identity_nonce: 1, + }, + platform_version, + ) + .await; + + let secp = Secp256k1::new(); + + let mut rng = StdRng::seed_from_u64(1292); + + let new_key_pair = Keypair::new(&secp, &mut rng); + + let mut new_key = IdentityPublicKeyInCreationV0 { + id: 2, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + key_type: ECDSA_SECP256K1, + read_only: false, + data: new_key_pair.public_key().serialize().to_vec().into(), + signature: Default::default(), + contract_bounds: Some(ContractBounds::SingleContract { + id: data_contract.id(), + }), + }; + + let update_transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 2, // Use nonce 2 since we used 1 for contract creation + add_public_keys: vec![IdentityPublicKeyInCreation::V0(new_key.clone())], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: master_key.id(), + signature: Default::default(), + } + .into(); + + let update_transition: StateTransition = update_transition.into(); + + let signable_bytes = update_transition + .signable_bytes() + .expect("expected signable bytes"); + + // Sign the new key with its own private key + let secret = new_key_pair.secret_key(); + let signature = + signer::sign(&signable_bytes, &secret.secret_bytes()).expect("expected to sign"); + + new_key.signature = signature.to_vec().into(); + + // Create the transition again with the signed key + let update_transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 2, + add_public_keys: vec![IdentityPublicKeyInCreation::V0(new_key)], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: master_key.id(), + signature: Default::default(), + } + .into(); + + let mut update_transition: StateTransition = update_transition.into(); + + // Sign the transition with the master key + update_transition.set_signature( + signer + .sign(&master_key, signable_bytes.as_slice()) + .await + .expect("expected to sign"), + ); + + let update_transition_bytes = update_transition + .serialize_to_bytes() + .expect("expected to serialize"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![update_transition_bytes.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + true, + None, + ) + .expect("expected to process state transition"); + + // We expect success - contract bound keys are allowed + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Verify the key was added + use drive::drive::identity::key::fetch::{IdentityKeysRequest, KeyRequestType}; + + let identity_keys_request = IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type: KeyRequestType::AllKeys, + limit: None, + offset: None, + }; + + let updated_partial_identity = platform + .drive + .fetch_identity_keys_as_partial_identity(identity_keys_request, None, platform_version) + .expect("expected to fetch identity") + .expect("expected identity to exist"); + + assert_eq!(updated_partial_identity.loaded_public_keys.len(), 3); // Original 2 + new contract bound key + + let contract_bound_key = updated_partial_identity + .loaded_public_keys + .get(&2) + .expect("expected to find key with id 2"); + + assert_eq!( + contract_bound_key.contract_bounds(), + Some(&ContractBounds::SingleContract { + id: data_contract.id() + }) + ); + } + #[tokio::test] async fn test_identity_update_adding_contract_bound_key_on_document_level() { use crate::execution::validation::state_transition::tests::{ From 6999335ba1365b2361dbfcb8a870edafb6924f3b Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 18 Sep 2026 17:56:12 -0500 Subject: [PATCH 2/2] fix(drive): store the current-key alias for bound encryption/decryption keys under the purpose subtree An IdentityUpdate adding an ENCRYPTION or DECRYPTION key bound to a contract whose config opts in with requiresIdentityEncryptionBoundedKey / requiresIdentityDecryptionBoundedKey = 2 (MultipleReferenceToLatest) failed inside Drive with a grovedb MissingReference and was dropped as an InternalError. The current-key alias is a sibling reference at the empty key, but for non-AUTHENTICATION purposes v1 wrote it one level above the purpose subtree that holds the key id it names, so it could not resolve. The AUTHENTICATION and document-type branches, and every reader (CurrentKeyOfKindRequest, identities_contract_keys_query), already used the purpose subtree. Both v1 methods now use the purpose subtree for every purpose. AllKeysOfKindRequest skips the empty key for every purpose so listings never repeat the alias. v1 is fixed in place: the old path never produced a committed block on any network (the batch always failed and the transition was removed from the proposal), so replay is unaffected and no migration is needed; v0 is untouched. Comments and the v14 changelog describe the real layout. The regression test now covers both purposes, both key-kind queries, and the refresh path on disable; a drive-level test covers the fetch and getIdentitiesContractKeys reads, the fee estimate, and grovedb consistency. Co-Authored-By: Claude Fable 5.1 --- .../state_transitions/identity_update/mod.rs | 293 +++++++++++------- .../v1/mod.rs | 21 +- .../drive/identity/contract_info/keys/mod.rs | 24 +- .../v1/mod.rs | 22 +- .../src/drive/identity/key/fetch/mod.rs | 234 ++++++++++++-- .../v1/mod.rs | 7 +- .../drive_identity_method_versions/v2.rs | 6 +- .../rs-platform-version/src/version/v14.rs | 6 +- 8 files changed, 436 insertions(+), 177 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs index cb84baa99e9..cb5f4e89844 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/mod.rs @@ -1976,17 +1976,31 @@ mod tests { ); } + /// A contract-level bound ENCRYPTION or DECRYPTION key whose contract opts in with + /// `MultipleReferenceToLatest` (2): the mode the JS SDK publishes by default. Drive used + /// to write the current-key alias one level above the purpose subtree, where its sibling + /// reference could not resolve, so the update died as an `InternalError`. #[tokio::test] async fn test_identity_update_adding_contract_bound_key_multiple_reference_to_latest() { use crate::execution::validation::state_transition::tests::{ register_contract_from_bytes, IdentityTestInfo, }; + use drive::config::DriveConfig; + use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyIDVec, KeyKindRequestType, KeyRequestType, + }; let platform_config = PlatformConfig { testing_configs: PlatformTestConfig { disable_instant_lock_signature_verification: true, ..Default::default() }, + // Consistency verification makes GroveDB reject two pending operations on one + // slot, so a duplicated alias write would fail here instead of silently winning. + drive: DriveConfig { + batching_consistency_verification: true, + ..Default::default() + }, ..Default::default() }; @@ -1997,6 +2011,8 @@ mod tests { .build_with_mock_rpc() .set_genesis_state(); + // The bytes below decode to this contract (bound-key requirements `0`); the test + // flips both requirements to `2` before registering it. // { // "$formatVersion": "1", // "id": "5pkMhyeaFjJfVMkFhLtJdDp2ofx6iqt7i9k6ckkHBwbs", @@ -2112,133 +2128,188 @@ mod tests { ) .await; + let bounds = ContractBounds::SingleContract { + id: data_contract.id(), + }; let secp = Secp256k1::new(); - let mut rng = StdRng::seed_from_u64(1292); - - let new_key_pair = Keypair::new(&secp, &mut rng); - - let mut new_key = IdentityPublicKeyInCreationV0 { - id: 2, - purpose: Purpose::ENCRYPTION, - security_level: SecurityLevel::MEDIUM, - key_type: ECDSA_SECP256K1, - read_only: false, - data: new_key_pair.public_key().serialize().to_vec().into(), - signature: Default::default(), - contract_bounds: Some(ContractBounds::SingleContract { - id: data_contract.id(), - }), + let pairs: Vec<(u32, Purpose, Keypair)> = vec![ + (2, Purpose::ENCRYPTION, Keypair::new(&secp, &mut rng)), + (3, Purpose::DECRYPTION, Keypair::new(&secp, &mut rng)), + ]; + let mut adds: Vec = pairs + .iter() + .map(|(id, purpose, pair)| IdentityPublicKeyInCreationV0 { + id: *id, + purpose: *purpose, + security_level: SecurityLevel::MEDIUM, + key_type: ECDSA_SECP256K1, + read_only: false, + data: pair.public_key().serialize().to_vec().into(), + signature: Default::default(), + contract_bounds: Some(bounds.clone()), + }) + .collect(); + let unsigned = |revision: u64, + nonce: u64, + add: Vec, + disable: Vec| + -> StateTransition { + IdentityUpdateTransition::from(IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision, + nonce, + add_public_keys: add + .into_iter() + .map(IdentityPublicKeyInCreation::V0) + .collect(), + disable_public_keys: disable, + user_fee_increase: 0, + signature_public_key_id: master_key.id(), + signature: Default::default(), + }) + .into() + }; + let apply = |transition: &StateTransition, time_ms: u64| { + let transaction = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![transition.serialize_to_bytes().unwrap()], + &platform_state, + &BlockInfo { + time_ms, + ..Default::default() + }, + &transaction, + platform_version, + true, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "update at {time_ms} must apply" + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + }; + let key_ids = |purpose: Purpose, kind: KeyKindRequestType| -> Vec { + platform + .drive + .fetch_identity_keys::( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type: KeyRequestType::ContractBoundKey( + data_contract.id().to_buffer(), + purpose, + kind, + ), + limit: Some(16), + offset: None, + }, + None, + platform_version, + ) + .expect("expected to fetch bound key ids") + }; + let fetch_key = |key_id: u32| { + platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type: KeyRequestType::SpecificKeys(vec![key_id]), + limit: Some(1), + offset: None, + }, + None, + platform_version, + ) + .expect("expected to fetch identity") + .expect("expected identity to exist") + .loaded_public_keys + .remove(&key_id) + .expect("expected the key") + }; + let assert_no_grovedb_issues = || { + let issues = platform + .drive + .grove + .visualize_verify_grovedb(None, true, false, &platform_version.drive.grove_version) + .expect("expected to verify grovedb"); + assert!(issues.is_empty(), "grovedb issues: {issues:?}"); }; - let update_transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { - identity_id: identity.id(), - revision: 1, - nonce: 2, // Use nonce 2 since we used 1 for contract creation - add_public_keys: vec![IdentityPublicKeyInCreation::V0(new_key.clone())], - disable_public_keys: vec![], - user_fee_increase: 0, - signature_public_key_id: master_key.id(), - signature: Default::default(), - } - .into(); - - let update_transition: StateTransition = update_transition.into(); - - let signable_bytes = update_transition + // Register both keys in one update: one current-key alias per purpose subtree. + let signable = unsigned(1, 2, adds.clone(), vec![]) .signable_bytes() .expect("expected signable bytes"); - - // Sign the new key with its own private key - let secret = new_key_pair.secret_key(); - let signature = - signer::sign(&signable_bytes, &secret.secret_bytes()).expect("expected to sign"); - - new_key.signature = signature.to_vec().into(); - - // Create the transition again with the signed key - let update_transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { - identity_id: identity.id(), - revision: 1, - nonce: 2, - add_public_keys: vec![IdentityPublicKeyInCreation::V0(new_key)], - disable_public_keys: vec![], - user_fee_increase: 0, - signature_public_key_id: master_key.id(), - signature: Default::default(), + for (key, (_, _, pair)) in adds.iter_mut().zip(&pairs) { + key.signature = signer::sign(&signable, &pair.secret_key().secret_bytes()) + .expect("expected to sign") + .to_vec() + .into(); } - .into(); - - let mut update_transition: StateTransition = update_transition.into(); - - // Sign the transition with the master key - update_transition.set_signature( + let mut registration = unsigned(1, 2, adds, vec![]); + registration.set_signature( signer - .sign(&master_key, signable_bytes.as_slice()) + .sign(&master_key, &signable) .await .expect("expected to sign"), ); + apply(®istration, 1000); + assert_no_grovedb_issues(); - let update_transition_bytes = update_transition - .serialize_to_bytes() - .expect("expected to serialize"); - - let transaction = platform.drive.grove.start_transaction(); - - let processing_result = platform - .platform - .process_raw_state_transitions( - &vec![update_transition_bytes.clone()], - &platform_state, - &BlockInfo::default(), - &transaction, - platform_version, - true, - None, - ) - .expect("expected to process state transition"); + for (id, purpose, _) in &pairs { + assert_eq!(fetch_key(*id).contract_bounds(), Some(&bounds)); + assert_eq!( + key_ids(*purpose, KeyKindRequestType::CurrentKeyOfKindRequest), + vec![*id], + "{purpose:?} current key" + ); + assert_eq!( + key_ids(*purpose, KeyKindRequestType::AllKeysOfKindRequest), + vec![*id], + "{purpose:?} listing must not repeat the current key alias" + ); + } - // We expect success - contract bound keys are allowed - assert_matches!( - processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::SuccessfulExecution { .. }] + // Disabling the encryption key refreshes its references, including the alias. + let mut revocation = unsigned(2, 3, vec![], vec![2]); + revocation.set_signature( + signer + .sign( + &master_key, + &revocation + .signable_bytes() + .expect("expected signable bytes"), + ) + .await + .expect("expected to sign"), ); + apply(&revocation, 2000); + assert_no_grovedb_issues(); - platform - .drive - .grove - .commit_transaction(transaction) - .unwrap() - .expect("expected to commit"); - - // Verify the key was added - use drive::drive::identity::key::fetch::{IdentityKeysRequest, KeyRequestType}; - - let identity_keys_request = IdentityKeysRequest { - identity_id: identity.id().to_buffer(), - request_type: KeyRequestType::AllKeys, - limit: None, - offset: None, - }; - - let updated_partial_identity = platform - .drive - .fetch_identity_keys_as_partial_identity(identity_keys_request, None, platform_version) - .expect("expected to fetch identity") - .expect("expected identity to exist"); - - assert_eq!(updated_partial_identity.loaded_public_keys.len(), 3); // Original 2 + new contract bound key - - let contract_bound_key = updated_partial_identity - .loaded_public_keys - .get(&2) - .expect("expected to find key with id 2"); - + assert_eq!(fetch_key(2).disabled_at(), Some(2000)); assert_eq!( - contract_bound_key.contract_bounds(), - Some(&ContractBounds::SingleContract { - id: data_contract.id() - }) + key_ids( + Purpose::ENCRYPTION, + KeyKindRequestType::CurrentKeyOfKindRequest + ), + vec![2] + ); + assert_eq!( + key_ids( + Purpose::DECRYPTION, + KeyKindRequestType::CurrentKeyOfKindRequest + ), + vec![3] ); } diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs index e242532a942..0ae92582413 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs @@ -299,18 +299,17 @@ impl Drive { if storage_key_requirements == StorageKeyRequirements::MultipleReferenceToLatest { // we also insert a sibling reference so we can query the current key + // The alias is a sibling reference, so it must live in the purpose + // subtree beside the key id it names, which is also where the + // current-key query reads it (`CurrentKeyOfKindRequest`). v0 wrote it + // one level up for encryption and decryption keys, where the sibling + // could not resolve, so such keys could never be registered. let sibling_ref_type_path = SiblingReference(key_id_bytes); - let sibling_path = if purpose == Purpose::AUTHENTICATION { - // A bound authentication key's current-key reference belongs beside - // its key IDs, under the purpose subtree. Keep legacy paths frozen. - identity_contract_info_group_path_key_purpose_vec( - &identity_id, - &root_id, - purpose, - ) - } else { - identity_contract_info_group_keys_path_vec(&identity_id, &root_id) - }; + let sibling_path = identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ); self.batch_insert( PathKeyElementInfo::<0>::PathKeyElement(( diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs index 22ca9b60890..a2fed9b86b7 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/mod.rs @@ -77,8 +77,10 @@ pub(crate) fn coalesce_current_key_alias_operations(operations: &mut Vec Option<(&KeyInfoPath, Option)> { @@ -362,10 +364,12 @@ mod tests { &document_type_group, Purpose::AUTHENTICATION, ); - // Legacy encryption and decryption bounds alias the current key at the keys level. - let legacy_alias_path = + // The keys level holds the purpose subtrees; v1 never writes an alias there (v0 did, + // for encryption and decryption keys, where it could not resolve). A sibling reference + // at its empty key is not a slot and must not be recognized. + let keys_level_path = identity_contract_info_group_keys_path_vec(&identity_id, &contract_id); - // A purpose-level alias for another purpose must not be recognized either. + // The alias slot of another purpose is not coalesced either. let encryption_purpose_path = identity_contract_info_group_path_key_purpose_vec( &identity_id, &contract_id, @@ -375,10 +379,10 @@ mod tests { let mut operations = vec![ alias_refresh(contract_auth_path.clone(), 1), alias_insert(contract_auth_path.clone(), 2), - alias_insert(legacy_alias_path.clone(), 5), + alias_insert(keys_level_path.clone(), 5), alias_insert(document_type_auth_path.clone(), 9), alias_insert(contract_auth_path.clone(), 3), - alias_insert(legacy_alias_path.clone(), 6), + alias_insert(keys_level_path.clone(), 6), alias_refresh(encryption_purpose_path.clone(), 7), alias_insert(encryption_purpose_path.clone(), 8), alias_insert(document_type_auth_path.clone(), 10), @@ -390,15 +394,15 @@ mod tests { assert_eq!( operations, vec![ - alias_insert(legacy_alias_path.clone(), 5), + alias_insert(keys_level_path.clone(), 5), alias_insert(contract_auth_path, 3), - alias_insert(legacy_alias_path, 6), + alias_insert(keys_level_path, 6), alias_refresh(encryption_purpose_path.clone(), 7), alias_insert(encryption_purpose_path, 8), alias_insert(document_type_auth_path, 10), ], "each AUTHENTICATION slot keeps the insertion naming its highest key id, in place; \ - legacy and other-purpose aliases are untouched" + keys-level and other-purpose writes are untouched" ); } diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs index 0fe87b6fb96..c4524abafad 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v1/mod.rs @@ -1,7 +1,6 @@ use crate::drive::identity::contract_info::keys::IdentityDataContractKeyApplyInfo; use crate::drive::identity::{ - identity_contract_info_group_keys_path_vec, identity_contract_info_group_path_key_purpose_vec, - identity_key_location_within_identity_vec, + identity_contract_info_group_path_key_purpose_vec, identity_key_location_within_identity_vec, }; use crate::drive::Drive; use crate::error::contract::DataContractError; @@ -207,19 +206,14 @@ impl Drive { if storage_key_requirements == StorageKeyRequirements::MultipleReferenceToLatest { // we also refresh the sibling reference, so we can query the current key + // The alias lives in the purpose subtree for every purpose, beside the + // key id it names (see `add_contract_info_operations_v1`). let sibling_ref_type_path = SiblingReference(key_id_bytes); - let sibling_path = if purpose == Purpose::AUTHENTICATION { - // A bound authentication key's current-key reference belongs beside - // its key IDs, under the purpose subtree. Legacy purposes keep - // their historical path (see v0). - identity_contract_info_group_path_key_purpose_vec( - &identity_id, - &root_id, - purpose, - ) - } else { - identity_contract_info_group_keys_path_vec(&identity_id, &root_id) - }; + let sibling_path = identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ); // Untrusted refresh: the slot may point at a newer key covering the same // contract, so only the stored value hash is rebuilt; a trusted refresh diff --git a/packages/rs-drive/src/drive/identity/key/fetch/mod.rs b/packages/rs-drive/src/drive/identity/key/fetch/mod.rs index 191d552272d..c0c0dce09d8 100644 --- a/packages/rs-drive/src/drive/identity/key/fetch/mod.rs +++ b/packages/rs-drive/src/drive/identity/key/fetch/mod.rs @@ -17,10 +17,7 @@ use { dpp::identity::{KeyID, Purpose, SecurityLevel}, grovedb::{PathQuery, SizedQuery}, integer_encoding::VarInt, - std::{ - collections::{BTreeMap, BTreeSet}, - ops::RangeFull, - }, + std::collections::{BTreeMap, BTreeSet}, }; #[cfg(feature = "server")] @@ -58,11 +55,18 @@ mod fetch_identity_keys; /// A kind is a purpose/security level pair /// Do you want to get all keys in that pair /// Or just the current one? +/// +/// For contract-bound keys the purpose subtree keeps its current key at the empty key: the +/// only key under `StorageKeyRequirements::Unique`, or a sibling reference to the newest key id +/// under `MultipleReferenceToLatest` (every bound authentication key). The key id entries hold +/// every key registered under `Multiple` or `MultipleReferenceToLatest`. #[derive(Clone, Copy)] pub enum KeyKindRequestType { /// Get only the last key of a certain kind CurrentKeyOfKindRequest, - /// Get all keys of a certain kind + /// Get all keys of a certain kind. For contract-bound keys this lists the key id entries, + /// skipping the current-key slot so the newest key is not repeated; a `Unique` bound key + /// has no key id entry and is read with `CurrentKeyOfKindRequest`. AllKeysOfKindRequest, } @@ -952,13 +956,9 @@ impl IdentityKeysRequest { Query::new_single_key(vec![]) } AllKeysOfKindRequest => { - if purpose == Purpose::AUTHENTICATION { - // Bound authentication keys keep their current-key alias at the - // empty key of the purpose subtree; listing must not repeat it. - Query::new_single_query_item(QueryItem::RangeAfter(vec![]..)) - } else { - Query::new_single_query_item(QueryItem::RangeFull(RangeFull)) - } + // Every purpose keeps its current-key alias at the empty key of the + // purpose subtree; listing must not repeat it. + Query::new_single_query_item(QueryItem::RangeAfter(vec![]..)) } }; PathQuery { @@ -989,13 +989,9 @@ impl IdentityKeysRequest { Query::new_single_key(vec![]) } AllKeysOfKindRequest => { - if purpose == Purpose::AUTHENTICATION { - // Bound authentication keys keep their current-key alias at the - // empty key of the purpose subtree; listing must not repeat it. - Query::new_single_query_item(QueryItem::RangeAfter(vec![]..)) - } else { - Query::new_single_query_item(QueryItem::RangeFull(RangeFull)) - } + // Every purpose keeps its current-key alias at the empty key of the + // purpose subtree; listing must not repeat it. + Query::new_single_query_item(QueryItem::RangeAfter(vec![]..)) } }; PathQuery { @@ -1575,15 +1571,201 @@ mod tests { let identity_id: [u8; 32] = [18u8; 32]; let contract_id: [u8; 32] = [19u8; 32]; - let request = IdentityKeysRequest { - identity_id, - request_type: ContractBoundKey(contract_id, Purpose::ENCRYPTION, AllKeysOfKindRequest), - limit: None, - offset: None, + for purpose in [ + Purpose::AUTHENTICATION, + Purpose::ENCRYPTION, + Purpose::DECRYPTION, + ] { + let request = IdentityKeysRequest { + identity_id, + request_type: ContractBoundKey(contract_id, purpose, AllKeysOfKindRequest), + limit: None, + offset: None, + }; + + let path_query = request.into_path_query(); + assert!(path_query.query.limit.is_none()); + // The current-key alias at the empty key must not be listed for any purpose. + assert_eq!( + path_query.query.query.items, + vec![QueryItem::RangeAfter(vec![]..)], + "{purpose:?}" + ); + } + } + + /// A contract-level bound ENCRYPTION or DECRYPTION key stored under + /// `MultipleReferenceToLatest` keeps its current-key alias at the empty key of its purpose + /// subtree, where the current-key query reads it, and the alias must not be repeated when + /// the keys of that kind are listed. + #[test] + fn test_fetch_contract_bound_encryption_and_decryption_keys_with_multiple_reference_to_latest() + { + use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::config::v0::DataContractConfigSettersV0; + use dpp::data_contract::storage_requirements::keys_for_document_type::StorageKeyRequirements; + use dpp::identity::contract_bounds::ContractBounds; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::serialization::PlatformSerializable; + use dpp::tests::fixtures::get_dashpay_contract_fixture; + use rand::SeedableRng; + + let platform_version = PlatformVersion::latest(); + let drive = setup_drive_with_initial_state_structure(None); + let transaction = drive.grove.start_transaction(); + + let identity = Identity::random_identity(2, Some(4242), platform_version) + .expect("expected a random identity"); + drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + Some(&transaction), + platform_version, + ) + .expect("expected to insert identity"); + + // The DashPay contract opts document-type keys in with `2`; opt the contract itself in. + let mut contract = + get_dashpay_contract_fixture(Some(identity.id()), 1, platform_version.protocol_version) + .data_contract_owned(); + contract + .config_mut() + .set_requires_identity_encryption_bounded_key(Some( + StorageKeyRequirements::MultipleReferenceToLatest, + )); + contract + .config_mut() + .set_requires_identity_decryption_bounded_key(Some( + StorageKeyRequirements::MultipleReferenceToLatest, + )); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + None, + Some(&transaction), + platform_version, + ) + .expect("expected to apply contract"); + + let bounds = ContractBounds::SingleContract { id: contract.id() }; + let mut rng = rand::rngs::StdRng::seed_from_u64(4243); + let keys: Vec = [ + (2u32, Purpose::ENCRYPTION), + (3, Purpose::DECRYPTION), + (4, Purpose::ENCRYPTION), + ] + .into_iter() + .map(|(id, purpose)| { + IdentityPublicKey::random_key_with_known_attributes( + id, + &mut rng, + purpose, + SecurityLevel::MEDIUM, + KeyType::ECDSA_SECP256K1, + Some(bounds.clone()), + platform_version, + ) + .expect("expected a bound key") + .0 + }) + .collect(); + for key in &keys { + let register = |apply: bool| { + drive + .add_new_unique_keys_to_identity( + identity.id().to_buffer(), + vec![key.clone()], + &BlockInfo::default(), + apply, + Some(&transaction), + platform_version, + ) + .expect("expected to add a bound key") + }; + let estimated = register(false); + let actual = register(true); + // The estimate prices the purpose subtree with room for the alias reference. + assert!( + estimated.processing_fee >= actual.processing_fee, + "estimate {} must cover execution {}", + estimated.processing_fee, + actual.processing_fee + ); + assert!(estimated.storage_fee >= actual.storage_fee); + } + + let key_ids = |purpose: Purpose, kind: KeyKindRequestType| -> KeyIDVec { + drive + .fetch_identity_keys( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type: ContractBoundKey(contract.id().to_buffer(), purpose, kind), + limit: Some(16), + offset: None, + }, + Some(&transaction), + platform_version, + ) + .expect("expected to fetch bound key ids") + }; + assert_eq!( + key_ids(Purpose::ENCRYPTION, CurrentKeyOfKindRequest), + vec![4], + "the newest encryption key is current" + ); + assert_eq!( + key_ids(Purpose::ENCRYPTION, AllKeysOfKindRequest), + vec![2, 4], + "listing must not repeat the current key alias" + ); + assert_eq!( + key_ids(Purpose::DECRYPTION, CurrentKeyOfKindRequest), + vec![3] + ); + assert_eq!(key_ids(Purpose::DECRYPTION, AllKeysOfKindRequest), vec![3]); + + // `getIdentitiesContractKeys` reads the same current-key slot. + let contract_keys = drive + .fetch_identities_contract_keys( + &[identity.id().to_buffer()], + &contract.id().to_buffer(), + None, + vec![Purpose::ENCRYPTION, Purpose::DECRYPTION], + Some(&transaction), + platform_version, + ) + .expect("expected to fetch the identity's contract keys"); + let expected_current = |key_id: u32| { + keys.iter() + .find(|key| key.id() == key_id) + .expect("expected the key") + .serialize_to_bytes() + .expect("expected to serialize the key") }; + assert_eq!( + contract_keys.get(&identity.id()), + Some(&BTreeMap::from([ + (Purpose::ENCRYPTION, expected_current(4)), + (Purpose::DECRYPTION, expected_current(3)), + ])) + ); - let path_query = request.into_path_query(); - assert!(path_query.query.limit.is_none()); + let issues = drive + .grove + .visualize_verify_grovedb( + Some(&transaction), + true, + false, + &platform_version.drive.grove_version, + ) + .expect("expected to verify grovedb"); + assert!(issues.is_empty(), "grovedb issues: {issues:?}"); } #[test] diff --git a/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/v1/mod.rs b/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/v1/mod.rs index fc2d568163b..d33d62e0753 100644 --- a/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/v1/mod.rs +++ b/packages/rs-drive/src/util/operations/apply_batch_low_level_drive_operations/v1/mod.rs @@ -12,9 +12,10 @@ use std::collections::HashMap; impl Drive { /// Applies a batch of Drive operations to groveDB. /// - /// v1 first coalesces the current-key alias writes of bound authentication keys, so an - /// identity update that registers and revokes keys covering one contract queues a single - /// operation per alias slot (see [`coalesce_current_key_alias_operations`]). + /// v1 first coalesces the current-key alias writes of bound keys stored under + /// `MultipleReferenceToLatest`, so an identity update that registers and revokes keys + /// covering one contract queues a single operation per alias slot (see + /// [`coalesce_current_key_alias_operations`]). pub(crate) fn apply_batch_low_level_drive_operations_v1( &self, estimated_costs_only_with_layer_info: Option< diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs index 56dc684b180..7d431ed83cb 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_identity_method_versions/v2.rs @@ -19,7 +19,11 @@ use crate::version::drive_versions::drive_identity_method_versions::{ /// * `contract_info.add_potential_contract_info_for_contract_bounded_key` 0 -> 1 and /// `contract_info.refresh_potential_contract_info_key_references` 0 -> 1: /// write and refresh contract-bound authentication-key references. Both v0s preserve the -/// historical rejection of authentication keys with contract bounds before v14. +/// historical rejection of authentication keys with contract bounds before v14. Both v1s also +/// store the current-key alias of a contract-level encryption or decryption key bound under +/// `MultipleReferenceToLatest` in the key's purpose subtree, where the current-key query reads +/// it; both v0s wrote it one level up, where the sibling reference could not resolve, so such +/// keys could never be registered before v14 (nothing is stored at the v0 path on any network). /// * `keys.insert.insert_new_unique_key` 0 -> 1 and `keys.insert.insert_new_non_unique_key` /// 0 -> 1: a key that carries a budget also gets its remaining budget written to the identity's /// key budgets subtree. Keys cannot carry a budget before v14, so both v0s never write it. diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index c4675fe6688..fc3f7ccfbf9 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -284,7 +284,11 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// Contract-bound authentication keys activate through contract-bounds validation v2, /// identity-signature validation v1 and batch advanced-structure v1. Identity creation /// validates key bounds (state v1) and identity-update state v1 retains the contract -/// lookup fees; Drive identity methods v2 index and refresh the bound keys. +/// lookup fees; Drive identity methods v2 index and refresh the bound keys. The same v1 +/// contract-info methods also store the current-key alias of a contract-level encryption or +/// decryption key bound under `MultipleReferenceToLatest` in its purpose subtree, where the +/// current-key query reads it; v0 wrote it one level up, where its sibling reference could +/// not resolve, so registering such a key failed inside Drive on every earlier version. /// Contract group bounds on authentication keys ride the same versions: contract-bounds /// validation v2 admits them, batch transform v2 resolves the member contract's group /// memberships into the action (only for a group-bound signing key) for advanced-structure v1 to judge, and shielded-proof validation v1 refuses them in identity creation from the