diff --git a/docs/protocol/contract-scoped-authentication.md b/docs/protocol/contract-scoped-authentication.md new file mode 100644 index 00000000000..873a92cf359 --- /dev/null +++ b/docs/protocol/contract-scoped-authentication.md @@ -0,0 +1,106 @@ +# Contract-scoped authentication keys + +Protocol version 14 adds application authentication scopes. A wallet can register +a separate key for an application while retaining the identity's master key. +Validators enforce the registered scope on every batch member. Existing identity +ownership, key purpose, security level and document rules still apply. + +## Registering an application key + +The key must have AUTHENTICATION purpose and a non-MASTER security level. A HIGH +key is suitable for normal document operations. Its `contractBounds` is a new +`scoped` variant containing a versioned authentication scope: + +- `contracts`: explicit contract IDs with optional document-type restrictions. +- `permissions`: an action bitmask shared by every listed contract. +- `expiresAt`: an optional expiry in milliseconds, checked against block time. + +A missing/null document-type restriction authorizes all types in that contract, +including types added by later contract updates. An empty array is invalid. +Contract IDs and document-type names must be sorted and unique on the wire. There +are at most 16 contracts and 16 types per contract, and the encoded scope must not +exceed 2048 bytes. The WASM constructor sorts entries and rejects duplicates. + +For an application that creates, updates and deletes documents and pays their +configured token fees, construct the bounds with the WASM SDK: + +```javascript +const P = wasm.AuthenticationPermission; +const bounds = wasm.ContractBounds.Scoped( + [ + { id: socialContractId, documentTypes: ['like', 'post'] }, + { id: profileContractId, documentTypes: ['profile'] }, + ], + P.DocumentCreate | P.DocumentReplace | P.DocumentDelete | P.DocumentTokenPayment, + BigInt(Date.now() + 24 * 60 * 60 * 1000), +); + +const keyToAdd = new wasm.IdentityPublicKeyInCreation({ + keyId: nextKeyId, + purpose: 'authentication', + securityLevel: 'high', + keyType: 'ecdsa_hash160', + isReadOnly: false, + data: applicationPublicKeyHash160, + signature: new Uint8Array(), + contractBounds: bounds, +}); +``` + +Use the normal wallet-authorized identity-update procedure to register the key. +The registration signature binds the scope as well as the public-key material. +The browser only needs the application key's private material. Registering a +scope requires its referenced contracts/types to exist and its expiry, if any, +to be in the future. No contract encryption-key opt-in or unique-key setting is +required for scoped authentication. + +## Permissions and token fees + +Document create, replace, delete, ownership transfer, price updates and purchases +have separate bits. Index-only deletion uses the delete bit. Standalone token +transition kinds also have separate bits. New/unknown bits are rejected. + +`DocumentTokenPayment` permits the actual contract-defined token cost of an +otherwise-authorized document action. It also covers fees using a token issued +by another contract. That does not authorize document writes or standalone token +operations on the issuing contract. Without the bit, a document action with a +positive token cost is rejected, even if its create/replace/delete bit is set. + +A document-token payment permission does not implicitly authorize token transfer, +burn, mint, purchase or administration transitions. Explicitly granting one of +those bits still cannot override its normal purpose/security/ownership rules. +Contract updates can change document token fees; v0 scopes do not pin the fee +amount or currency. + +## Expiry, revocation and failures + +A key is expired when executing block time is greater than or equal to its expiry. +Mempool checks use the last committed block information; a transaction may expire +between admission and execution. Disable the key through the normal identity +update to revoke it. Extending expiry or expanding permissions requires a +wallet-authorized replacement. Expired keys are not automatically deleted. + +Scoped keys cannot execute non-batch transitions, including identity-key updates, +contract creation/updates, credit transfers/withdrawals or masternode votes. +Expired keys and non-batch use fail in identity-signature authorization. + +Batch scope violations follow normal paid validation-failure handling. Requested +document/token operations do not execute, but Platform credit validation fees +can be charged and the first batch member's identity-contract nonce can advance, +even if that member is outside the scope. Replays follow the usual nonce rules. + +There are no per-key budgets in scope version 0. A stolen key can exhaust credit +balances through fees and permitted token balances through allowed operations. +Expiry limits the time window, not total financial loss. + +## Compatibility + +The scoped variant is appended to the existing bounds enum; old key encodings +remain unchanged. Older protocols reject scoped registration, and older clients +cannot be assumed to decode scoped keys. SDK signing performs local structural +checks, but validator checks against current state remain authoritative. + +The native key ABI carries an encoded scope pointer/length. Native libraries, +generated headers and Swift/Kotlin consumers must be updated together. Key query, +persistence, restore and refresh paths must preserve scope metadata. It must +never be dropped or reconstructed as an unrestricted key. diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index d1bed746659..eeb97402b56 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -1,3 +1,4 @@ +use crate::consensus::basic::identity::InvalidAuthenticationScopeError; use crate::errors::ProtocolError; use bincode::{Decode, Encode}; use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; @@ -699,6 +700,8 @@ pub enum BasicError { #[error(transparent)] DataContractInvalidRequiredFieldsUpdateError(DataContractInvalidRequiredFieldsUpdateError), + #[error(transparent)] + InvalidAuthenticationScopeError(InvalidAuthenticationScopeError), } impl From for ConsensusError { diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs new file mode 100644 index 00000000000..283ee13b0e0 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Invalid authentication scope: {reason}")] +#[platform_serialize(unversioned)] +pub struct InvalidAuthenticationScopeError { + reason: String, +} +impl InvalidAuthenticationScopeError { + pub fn new(reason: String) -> Self { + Self { reason } + } + pub fn reason(&self) -> &String { + &self.reason + } +} +impl From for ConsensusError { + fn from(error: InvalidAuthenticationScopeError) -> Self { + Self::BasicError(BasicError::InvalidAuthenticationScopeError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs index 9ab0839536d..dd2459ca9c4 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs @@ -68,3 +68,6 @@ mod missing_master_public_key_error; mod not_implemented_credit_withdrawal_transition_pooling_error; mod too_many_master_public_key_error; mod withdrawal_output_script_not_allowed_when_signing_with_owner_key; + +mod invalid_authentication_scope_error; +pub use invalid_authentication_scope_error::InvalidAuthenticationScopeError; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index c7a00352876..410efe61ae6 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -205,6 +205,7 @@ impl ErrorWithCode for BasicError { Self::WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError(_) => 10532, Self::InvalidKeyPurposeForContractBoundsError(_) => 10533, Self::IdentityAssetLockTransactionTooManyInputsError(_) => 10534, + Self::InvalidAuthenticationScopeError(_) => 10535, // State Transition Errors: 10600-10699 Self::InvalidStateTransitionTypeError { .. } => 10600, @@ -264,6 +265,9 @@ impl ErrorWithCode for SignatureError { Self::BasicBLSError(_) => 20010, Self::InvalidSignaturePublicKeyPurposeError(_) => 20011, Self::UncompressedPublicKeyNotAllowedError(_) => 20012, + Self::ScopedKeyOutOfScopeError(_) => 20015, + Self::ScopedKeyExpiredError(_) => 20014, + Self::ScopedKeyNonBatchError(_) => 20013, } } } diff --git a/packages/rs-dpp/src/errors/consensus/signature/mod.rs b/packages/rs-dpp/src/errors/consensus/signature/mod.rs index 91ff05114bd..6426c1ce61c 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/mod.rs @@ -27,3 +27,12 @@ pub use crate::consensus::signature::signature_error::SignatureError; pub use crate::consensus::signature::signature_should_not_be_present_error::SignatureShouldNotBePresentError; pub use crate::consensus::signature::uncompressed_public_key_not_allowed_error::UncompressedPublicKeyNotAllowedError; pub use crate::consensus::signature::wrong_public_key_purpose_error::WrongPublicKeyPurposeError; + +mod scoped_key_non_batch_error; +pub use scoped_key_non_batch_error::ScopedKeyNonBatchError; + +mod scoped_key_expired_error; +pub use scoped_key_expired_error::ScopedKeyExpiredError; + +mod scoped_key_out_of_scope_error; +pub use scoped_key_out_of_scope_error::ScopedKeyOutOfScopeError; diff --git a/packages/rs-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs new file mode 100644 index 00000000000..159036496e5 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Scoped key {public_key_id} has expired")] +#[platform_serialize(unversioned)] +pub struct ScopedKeyExpiredError { + public_key_id: u32, +} +impl ScopedKeyExpiredError { + pub fn new(public_key_id: u32) -> Self { + Self { public_key_id } + } + pub fn public_key_id(&self) -> &u32 { + &self.public_key_id + } +} +impl From for ConsensusError { + fn from(error: ScopedKeyExpiredError) -> Self { + Self::SignatureError(SignatureError::ScopedKeyExpiredError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs new file mode 100644 index 00000000000..8773ddeb0d7 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Scoped key {public_key_id} cannot sign a non-batch transition")] +#[platform_serialize(unversioned)] +pub struct ScopedKeyNonBatchError { + public_key_id: u32, +} +impl ScopedKeyNonBatchError { + pub fn new(public_key_id: u32) -> Self { + Self { public_key_id } + } + pub fn public_key_id(&self) -> &u32 { + &self.public_key_id + } +} +impl From for ConsensusError { + fn from(error: ScopedKeyNonBatchError) -> Self { + Self::SignatureError(SignatureError::ScopedKeyNonBatchError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs new file mode 100644 index 00000000000..1492707d00e --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs @@ -0,0 +1,28 @@ +use crate::consensus::signature::SignatureError; +use crate::consensus::ConsensusError; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Batch member is outside key {public_key_id} scope")] +#[platform_serialize(unversioned)] +pub struct ScopedKeyOutOfScopeError { + public_key_id: u32, +} +impl ScopedKeyOutOfScopeError { + pub fn new(public_key_id: u32) -> Self { + Self { public_key_id } + } + pub fn public_key_id(&self) -> &u32 { + &self.public_key_id + } +} +impl From for ConsensusError { + fn from(error: ScopedKeyOutOfScopeError) -> Self { + Self::SignatureError(SignatureError::ScopedKeyOutOfScopeError(error)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs index 72b0e7afb1a..fefe04e76dd 100644 --- a/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs +++ b/packages/rs-dpp/src/errors/consensus/signature/signature_error.rs @@ -1,3 +1,6 @@ +use crate::consensus::signature::ScopedKeyExpiredError; +use crate::consensus::signature::ScopedKeyNonBatchError; +use crate::consensus::signature::ScopedKeyOutOfScopeError; use crate::consensus::signature::{ BasicBLSError, BasicECDSAError, IdentityNotFoundError, InvalidIdentityPublicKeyTypeError, InvalidSignaturePublicKeySecurityLevelError, InvalidStateTransitionSignatureError, @@ -60,6 +63,14 @@ pub enum SignatureError { #[error(transparent)] UncompressedPublicKeyNotAllowedError(UncompressedPublicKeyNotAllowedError), + #[error(transparent)] + ScopedKeyNonBatchError(ScopedKeyNonBatchError), + + #[error(transparent)] + ScopedKeyExpiredError(ScopedKeyExpiredError), + + #[error(transparent)] + ScopedKeyOutOfScopeError(ScopedKeyOutOfScopeError), } impl From for ConsensusError { diff --git a/packages/rs-dpp/src/identity/identity.rs b/packages/rs-dpp/src/identity/identity.rs index 86aee2c2d14..da7c0737207 100644 --- a/packages/rs-dpp/src/identity/identity.rs +++ b/packages/rs-dpp/src/identity/identity.rs @@ -29,6 +29,11 @@ use std::collections::{BTreeMap, BTreeSet}; /// The identity is not stored inside of drive, because of this, the serialization is mainly for /// transport, the serialization of the identity will include the version, so no passthrough or /// untagged is needed here +/// +/// The 256 MiB decoding budget accommodates `IDENTITY_MAX_KEYS` (15,000) at +/// 16 KiB per scoped key, plus the key map and identity fields. Bincode counts +/// container allocations as well as wire bytes. Individual authentication scopes +/// still have a separate 2 KiB wire limit enforced by scope validation. #[derive(Debug, Clone, PartialEq, From)] #[cfg_attr( feature = "serde-conversion", @@ -39,7 +44,7 @@ use std::collections::{BTreeMap, BTreeSet}; #[cfg_attr( feature = "identity-serialization", derive(Encode, Decode, PlatformDeserialize, PlatformSerialize), - platform_serialize(limit = 15000, unversioned) + platform_serialize(limit = 268435456, unversioned) )] #[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] pub enum Identity { @@ -335,6 +340,81 @@ mod tests { }) } + #[cfg(feature = "identity-serialization")] + #[test] + fn should_decode_full_identities_with_large_scoped_key_sets() { + use crate::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractBounds, ContractScope, + }; + use crate::identity::fields::IDENTITY_MAX_KEYS; + use crate::serialization::{PlatformDeserializable, PlatformSerializable}; + + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: (1..=16) + .map(|id| ContractScope { + id: Identifier::from([id; 32]), + document_types: Some((0..16).map(|n| format!("t{n:03}")).collect()), + }) + .collect(), + permissions: permissions::ALL, + expires_at: Some(u64::MAX), + }); + scope.validate().unwrap(); + + for count in [8, u32::from(IDENTITY_MAX_KEYS)] { + let mut public_keys = BTreeMap::from([(0, sample_key(0))]); + for id in 1..count { + let mut key = IdentityPublicKeyV0 { + id, + key_type: KeyType::BLS12_381, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: Some(ContractBounds::Scoped(scope.clone())), + data: vec![0x42; 48].into(), + read_only: false, + disabled_at: None, + }; + // Full identities retain disabled keys too. + if id % 2 == 0 { + key.disabled_at = Some(u64::MAX); + } + public_keys.insert(id, key.into()); + } + let identity: Identity = IdentityV0 { + id: Identifier::from([0x42; 32]), + public_keys, + balance: u64::MAX, + revision: u64::MAX, + } + .into(); + let bytes = identity.serialize_to_bytes().unwrap(); + assert_eq!( + Identity::deserialize_from_bytes(&bytes).unwrap(), + identity, + "full identity with {count} keys must round-trip" + ); + } + } + + #[cfg(feature = "identity-serialization")] + #[test] + fn should_reject_full_identity_with_excessive_declared_key_allocation() { + use crate::serialization::PlatformDeserializable; + + // Valid V0 tag and identity ID, followed by a forged public-key map length. + // The decoder must enforce its allocation budget before reading any keys. + let bytes = bincode::encode_to_vec( + (0_u8, Identifier::from([0x42; 32]), u64::MAX), + bincode::config::standard().with_big_endian(), + ) + .unwrap(); + assert!(matches!( + Identity::deserialize_from_bytes(&bytes), + Err(ProtocolError::MaxEncodedBytesReachedError { .. }) + )); + } + #[test] fn default_versioned_returns_default_v0() { let identity = diff --git a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs new file mode 100644 index 00000000000..91221d7bde7 --- /dev/null +++ b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/authentication_scope.rs @@ -0,0 +1,551 @@ +//! Immutable application delegation. Budgets are deliberately not part of V0. +use crate::identifier::Identifier; +use crate::identity::TimestampMillis; +#[cfg(feature = "value-conversion")] +use crate::serialization::ValueConvertible; +#[cfg(feature = "json-conversion")] +use crate::serialization::{json_safe_fields, JsonConvertible}; +use crate::ProtocolError; +use bincode::{Decode, Encode}; +use serde::{Deserialize, Serialize}; + +pub const MAX_SCOPE_BYTES: usize = 2048; +pub const MAX_SCOPE_CONTRACTS: usize = 16; +pub const MAX_SCOPE_DOCUMENT_TYPES: usize = 16; + +/// Stable wire bits. Unknown bits are rejected, never ignored. +pub mod permissions { + pub const DOCUMENT_CREATE: u32 = 1 << 0; + pub const DOCUMENT_REPLACE: u32 = 1 << 1; + pub const DOCUMENT_DELETE: u32 = 1 << 2; + pub const DOCUMENT_TRANSFER: u32 = 1 << 3; + pub const DOCUMENT_UPDATE_PRICE: u32 = 1 << 4; + pub const DOCUMENT_PURCHASE: u32 = 1 << 5; + pub const DOCUMENT_TOKEN_PAYMENT: u32 = 1 << 6; + pub const TOKEN_BURN: u32 = 1 << 7; + pub const TOKEN_MINT: u32 = 1 << 8; + pub const TOKEN_TRANSFER: u32 = 1 << 9; + pub const TOKEN_FREEZE: u32 = 1 << 10; + pub const TOKEN_UNFREEZE: u32 = 1 << 11; + pub const TOKEN_DESTROY_FROZEN_FUNDS: u32 = 1 << 12; + pub const TOKEN_CLAIM: u32 = 1 << 13; + pub const TOKEN_EMERGENCY_ACTION: u32 = 1 << 14; + pub const TOKEN_CONFIG_UPDATE: u32 = 1 << 15; + pub const TOKEN_DIRECT_PURCHASE: u32 = 1 << 16; + pub const TOKEN_SET_PRICE: u32 = 1 << 17; + pub const ALL: u32 = (1 << 18) - 1; +} + +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode, Serialize, Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct ContractScope { + pub id: Identifier, + /// None grants all document types; Some(empty) is invalid. + pub document_types: Option>, +} + +#[cfg_attr(feature = "json-conversion", json_safe_fields)] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode, Serialize, Deserialize, +)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticationScopeV0 { + pub contracts: Vec, + pub permissions: u32, + pub expires_at: Option, +} + +#[cfg_attr(feature = "json-conversion", derive(JsonConvertible))] +#[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] +#[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Encode, Decode, Serialize, Deserialize, +)] +#[serde(tag = "$formatVersion")] +pub enum AuthenticationScope { + #[serde(rename = "0")] + V0(AuthenticationScopeV0), +} + +impl AuthenticationScope { + pub fn v0(&self) -> &AuthenticationScopeV0 { + match self { + Self::V0(scope) => scope, + } + } + + pub fn contracts(&self) -> &[ContractScope] { + &self.v0().contracts + } + pub fn expires_at(&self) -> Option { + self.v0().expires_at + } + pub fn allows(&self, permission: u32) -> bool { + self.v0().permissions & permission == permission + } + pub fn is_expired(&self, time_ms: TimestampMillis) -> bool { + self.expires_at().is_some_and(|expiry| time_ms >= expiry) + } + pub fn allows_contract(&self, id: &Identifier) -> bool { + self.contracts().iter().any(|scope| scope.id == id) + } + pub fn allows_document(&self, id: &Identifier, name: &str) -> bool { + self.contracts().iter().any(|scope| { + scope.id == id + && scope + .document_types + .as_ref() + .is_none_or(|names| names.iter().any(|n| n == name)) + }) + } + + /// Validate before fetching any referenced contracts. + pub fn validate(&self) -> Result<(), ProtocolError> { + let invalid = + |reason: &str| ProtocolError::InvalidKeyContractBoundsError(reason.to_owned()); + let scope = self.v0(); + if scope.contracts.is_empty() || scope.contracts.len() > MAX_SCOPE_CONTRACTS { + return Err(invalid("scope must contain between 1 and 16 contracts")); + } + if scope.permissions == 0 || scope.permissions & !permissions::ALL != 0 { + return Err(invalid("scope must have a nonempty, known permission mask")); + } + if !scope + .contracts + .windows(2) + .all(|pair| pair[0].id < pair[1].id) + { + return Err(invalid("scope contract IDs must be sorted and unique")); + } + for contract in &scope.contracts { + if let Some(names) = &contract.document_types { + if names.is_empty() + || names.len() > MAX_SCOPE_DOCUMENT_TYPES + || names + .iter() + .any(|name| name.is_empty() || name.len() > MAX_SCOPE_BYTES) + || !names.windows(2).all(|pair| pair[0] < pair[1]) + { + return Err(invalid("scope document types must be nonempty, sorted, unique and at most 16 per contract")); + } + } + } + let bytes = bincode::encode_to_vec(self, bincode::config::standard()) + .map_err(|e| ProtocolError::EncodingError(e.to_string()))?; + if bytes.len() > MAX_SCOPE_BYTES { + return Err(invalid("encoded authentication scope exceeds 2048 bytes")); + } + Ok(()) + } + + /// Canonical native persistence / shielded preimage representation. + pub fn to_bytes(&self) -> Result, ProtocolError> { + self.validate()?; + bincode::encode_to_vec(self, bincode::config::standard()) + .map_err(|e| ProtocolError::EncodingError(e.to_string())) + } + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() > MAX_SCOPE_BYTES { + return Err(ProtocolError::DecodingError( + "scope exceeds 2048 bytes".into(), + )); + } + let (scope, consumed): (Self, usize) = bincode::decode_from_slice( + bytes, + bincode::config::standard().with_limit::<{ MAX_SCOPE_BYTES * 8 }>(), + ) + .map_err(|e| ProtocolError::DecodingError(e.to_string()))?; + if consumed != bytes.len() { + return Err(ProtocolError::DecodingError("trailing scope bytes".into())); + } + scope.validate()?; + Ok(scope) + } + + #[cfg(feature = "state-transitions")] + pub fn allows_transition( + &self, + transition: crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef<'_>, + ) -> bool { + use crate::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods; + use crate::state_transition::batch_transition::batched_transition::token_transition::TokenTransitionV0Methods; + use crate::state_transition::batch_transition::batched_transition::{ + BatchedTransitionRef, DocumentTransition, TokenTransition, + }; + use permissions::*; + let permission = match transition { + BatchedTransitionRef::Document(doc) => { + if !self.allows_document(&doc.data_contract_id(), doc.document_type_name()) { + return false; + } + match doc { + DocumentTransition::Create(_) => DOCUMENT_CREATE, + DocumentTransition::Replace(_) => DOCUMENT_REPLACE, + DocumentTransition::Delete(_) | DocumentTransition::IndexOnlyDelete(_) => { + DOCUMENT_DELETE + } + DocumentTransition::Transfer(_) => DOCUMENT_TRANSFER, + DocumentTransition::UpdatePrice(_) => DOCUMENT_UPDATE_PRICE, + DocumentTransition::Purchase(_) => DOCUMENT_PURCHASE, + } + } + BatchedTransitionRef::Token(token) => { + if !self.allows_contract(&token.data_contract_id()) { + return false; + } + match token { + TokenTransition::Burn(_) => TOKEN_BURN, + TokenTransition::Mint(_) => TOKEN_MINT, + TokenTransition::Transfer(_) => TOKEN_TRANSFER, + TokenTransition::Freeze(_) => TOKEN_FREEZE, + TokenTransition::Unfreeze(_) => TOKEN_UNFREEZE, + TokenTransition::DestroyFrozenFunds(_) => TOKEN_DESTROY_FROZEN_FUNDS, + TokenTransition::Claim(_) => TOKEN_CLAIM, + TokenTransition::EmergencyAction(_) => TOKEN_EMERGENCY_ACTION, + TokenTransition::ConfigUpdate(_) => TOKEN_CONFIG_UPDATE, + TokenTransition::DirectPurchase(_) => TOKEN_DIRECT_PURCHASE, + TokenTransition::SetPriceForDirectPurchase(_) => TOKEN_SET_PRICE, + } + } + }; + self.allows(permission) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture() -> AuthenticationScope { + AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([1; 32]), + document_types: Some(vec!["post".into()]), + }], + permissions: permissions::DOCUMENT_CREATE | permissions::DOCUMENT_TOKEN_PAYMENT, + expires_at: Some(100), + }) + } + #[test] + fn should_decode_stored_keys_with_large_scopes() { + use crate::identity::contract_bounds::ContractBounds; + use crate::identity::identity_public_key::v0::IdentityPublicKeyV0; + use crate::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use crate::serialization::{PlatformDeserializable, PlatformSerializable}; + + for contract_count in [8, 16] { + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: (0..contract_count) + .map(|id| ContractScope { + id: Identifier::from([id; 32]), + document_types: Some((0..16).map(|n| format!("t{n:02}")).collect()), + }) + .collect(), + permissions: permissions::ALL, + expires_at: Some(u64::MAX), + }); + scope.validate().unwrap(); + let key: IdentityPublicKey = IdentityPublicKeyV0 { + id: u32::MAX, + key_type: KeyType::ECDSA_SECP256K1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + data: vec![2; 33].into(), + read_only: false, + disabled_at: Some(u64::MAX), + contract_bounds: Some(ContractBounds::Scoped(scope)), + } + .into(); + let bytes = key.serialize_to_bytes().unwrap(); + assert_eq!( + IdentityPublicKey::deserialize_from_bytes(&bytes).unwrap(), + key + ); + } + } + + #[test] + fn should_preserve_scope_and_reject_trailing_bytes() { + let scope = fixture(); + let mut bytes = scope.to_bytes().unwrap(); + assert_eq!(AuthenticationScope::from_bytes(&bytes).unwrap(), scope); + bytes.push(0); + assert!(AuthenticationScope::from_bytes(&bytes).is_err()); + } + #[test] + fn should_restrict_contracts_types_actions_and_expiry() { + let scope = fixture(); + assert!(scope.allows_document(&Identifier::from([1; 32]), "post")); + assert!(!scope.allows_document(&Identifier::from([2; 32]), "post")); + assert!(!scope.allows_document(&Identifier::from([1; 32]), "profile")); + assert!(!scope.allows(permissions::TOKEN_TRANSFER)); + assert!(!scope.is_expired(99)); + assert!(scope.is_expired(100)); + } + #[test] + fn should_reject_empty_types_unknown_bits_and_duplicate_contracts() { + let AuthenticationScope::V0(original) = fixture(); + let mut scope = original.clone(); + scope.contracts[0].document_types = Some(vec![]); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + let mut scope = original.clone(); + scope.permissions |= 1 << 31; + assert!(AuthenticationScope::V0(scope).validate().is_err()); + let mut scope = original; + scope.contracts.push(scope.contracts[0].clone()); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + } + #[test] + fn should_bound_scope_size_and_distinguish_unrestricted_types() { + let AuthenticationScope::V0(mut scope) = fixture(); + scope.contracts[0].document_types = None; + let unrestricted = AuthenticationScope::V0(scope.clone()); + assert!(unrestricted.validate().is_ok()); + assert!(unrestricted.allows_document(&scope.contracts[0].id, "anything")); + scope.contracts[0].document_types = Some(vec!["a".repeat(MAX_SCOPE_BYTES)]); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + assert!(AuthenticationScope::from_bytes(&vec![0; MAX_SCOPE_BYTES + 1]).is_err()); + + let AuthenticationScope::V0(mut scope) = fixture(); + scope.contracts = (0..16) + .map(|id| ContractScope { + id: Identifier::from([id; 32]), + document_types: None, + }) + .collect(); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_ok()); + scope.contracts.push(ContractScope { + id: Identifier::from([16; 32]), + document_types: None, + }); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_err()); + scope.contracts.clear(); + assert!(AuthenticationScope::V0(scope).validate().is_err()); + + let AuthenticationScope::V0(mut scope) = fixture(); + let names = (0..16).map(|n| format!("type{n:02}")).collect::>(); + scope.contracts[0].document_types = Some(names.clone()); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_ok()); + scope.contracts[0] + .document_types + .as_mut() + .unwrap() + .push("type16".into()); + assert!(AuthenticationScope::V0(scope.clone()).validate().is_err()); + scope.contracts[0].document_types = Some(names); + scope.permissions = 0; + assert!(AuthenticationScope::V0(scope).validate().is_err()); + } + + #[cfg(feature = "state-transitions")] + #[test] + fn should_require_each_token_permission_independently_and_reject_foreign_contracts() { + use crate::state_transition::batch_transition::batched_transition::{ + token_transfer_transition::TokenTransferTransitionV0, BatchedTransitionRef, + TokenTransition, + }; + use permissions::*; + + let cases = [ + (TokenTransition::Burn(Default::default()), TOKEN_BURN), + (TokenTransition::Mint(Default::default()), TOKEN_MINT), + ( + TokenTransition::Transfer(TokenTransferTransitionV0::default().into()), + TOKEN_TRANSFER, + ), + (TokenTransition::Freeze(Default::default()), TOKEN_FREEZE), + ( + TokenTransition::Unfreeze(Default::default()), + TOKEN_UNFREEZE, + ), + ( + TokenTransition::DestroyFrozenFunds(Default::default()), + TOKEN_DESTROY_FROZEN_FUNDS, + ), + (TokenTransition::Claim(Default::default()), TOKEN_CLAIM), + ( + TokenTransition::EmergencyAction(Default::default()), + TOKEN_EMERGENCY_ACTION, + ), + ( + TokenTransition::ConfigUpdate(Default::default()), + TOKEN_CONFIG_UPDATE, + ), + ( + TokenTransition::DirectPurchase(Default::default()), + TOKEN_DIRECT_PURCHASE, + ), + ( + TokenTransition::SetPriceForDirectPurchase(Default::default()), + TOKEN_SET_PRICE, + ), + ]; + // Only the operation and contract are relevant to the authorization policy; + // amounts, recipients and other action fields are validated separately. + for (transition, required) in cases { + let member = BatchedTransitionRef::Token(&transition); + let mut scope = AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([0; 32]), + document_types: None, + }], + permissions: required, + expires_at: None, + }; + assert!(AuthenticationScope::V0(scope.clone()).allows_transition(member)); + scope.permissions = ALL & !required; + assert!(!AuthenticationScope::V0(scope.clone()).allows_transition(member), + "all other permissions, including document token fees, must not authorize {transition:?}"); + scope.permissions = ALL; + scope.contracts[0].id = Identifier::from([1; 32]); + assert!( + !AuthenticationScope::V0(scope).allows_transition(member), + "no permission may escape its contract" + ); + } + } + + #[cfg(all(feature = "json-conversion", feature = "value-conversion"))] + #[test] + fn should_round_trip_scoped_bounds_in_json_and_platform_value() { + use super::super::ContractBounds; + let bounds = ContractBounds::Scoped(fixture()); + let json = bounds.to_json().unwrap(); + assert_eq!(ContractBounds::from_json(json).unwrap(), bounds); + let value = bounds.to_object().unwrap(); + assert_eq!(ContractBounds::from_object(value).unwrap(), bounds); + } + + #[cfg(feature = "state-transitions")] + #[test] + fn should_reject_scoped_keys_before_activation_and_scoped_master_keys() { + use super::super::ContractBounds; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::state_transition::public_key_in_creation::{ + v0::IdentityPublicKeyInCreationV0, IdentityPublicKeyInCreation, + }; + let mut key = IdentityPublicKeyInCreationV0 { + id: 2, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + data: vec![1; 20].into(), + read_only: false, + signature: Default::default(), + contract_bounds: Some(ContractBounds::Scoped(fixture())), + }; + let old = crate::version::PlatformVersion::get(13).unwrap(); + let new = crate::version::PlatformVersion::latest(); + assert!( + !IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &[key.clone().into()], + false, + old + ) + .unwrap() + .is_valid() + ); + assert!( + IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &[key.clone().into()], + false, + new + ) + .unwrap() + .is_valid() + ); + key.security_level = SecurityLevel::MASTER; + assert!( + !IdentityPublicKeyInCreation::validate_identity_public_keys_structure( + &[key.into()], + false, + new + ) + .unwrap() + .is_valid() + ); + } + + #[cfg(feature = "state-transitions")] + #[test] + fn should_bind_every_scope_field_in_versioned_shielded_preimages() { + use super::super::ContractBounds; + use crate::address_funds::PlatformAddress; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::shielded::{ + identity_create_from_shielded_extra_sighash_data as versioned, + identity_create_from_shielded_extra_sighash_data_v0 as old, + identity_create_from_shielded_extra_sighash_data_v1 as new, + }; + use crate::state_transition::public_key_in_creation::{ + v0::IdentityPublicKeyInCreationV0, IdentityPublicKeyInCreation, + }; + let mut key = IdentityPublicKeyInCreationV0 { + id: 2, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + data: vec![1; 20].into(), + read_only: false, + signature: Default::default(), + contract_bounds: None, + }; + let fallback = PlatformAddress::P2pkh([2; 20]); + for address in [fallback, PlatformAddress::P2sh([3; 20])] { + for bounds in [ + None, + Some(ContractBounds::SingleContract { + id: Identifier::from([4; 32]), + }), + Some(ContractBounds::SingleContractDocumentType { + id: Identifier::from([4; 32]), + document_type_name: "legacy".into(), + }), + ] { + key.contract_bounds = bounds; + let legacy: IdentityPublicKeyInCreation = key.clone().into(); + let keys = std::slice::from_ref(&legacy); + let frozen = old(&[1; 32], 1, &address, keys).unwrap(); + assert_eq!(frozen, new(&[1; 32], 1, &address, keys).unwrap()); + for protocol in [13, 14] { + assert_eq!( + frozen, + versioned( + &[1; 32], + 1, + &address, + keys, + crate::version::PlatformVersion::get(protocol).unwrap() + ) + .unwrap(), + "legacy key preimage must remain stable under protocol {protocol}" + ); + } + } + } + key.contract_bounds = Some(ContractBounds::Scoped(fixture())); + assert!(old(&[1; 32], 1, &fallback, &[key.clone().into()]).is_err()); + let original = new(&[1; 32], 1, &fallback, &[key.clone().into()]).unwrap(); + for field in ["contract", "types", "permissions", "expiry"] { + let mut changed = key.clone(); + let Some(ContractBounds::Scoped(AuthenticationScope::V0(ref mut scope))) = + changed.contract_bounds + else { + unreachable!() + }; + match field { + "contract" => scope.contracts[0].id = Identifier::from([2; 32]), + "types" => scope.contracts[0].document_types = None, + "permissions" => scope.permissions |= permissions::DOCUMENT_DELETE, + "expiry" => scope.expires_at = Some(101), + _ => unreachable!(), + } + assert_ne!( + original, + new(&[1; 32], 1, &fallback, &[changed.into()]).unwrap(), + "must bind {field}" + ); + } + } +} diff --git a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs index 7052fea0813..29d8b974498 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs @@ -1,3 +1,4 @@ +pub mod authentication_scope; use crate::identifier::Identifier; use crate::identity::identity_public_key::contract_bounds::ContractBounds::{ SingleContract, SingleContractDocumentType, @@ -7,6 +8,7 @@ use crate::serialization::JsonConvertible; #[cfg(feature = "value-conversion")] use crate::serialization::ValueConvertible; use crate::ProtocolError; +pub use authentication_scope::{AuthenticationScope, AuthenticationScopeV0, ContractScope}; use bincode::{Decode, Encode}; use serde::{Deserialize, Serialize}; @@ -35,6 +37,8 @@ pub enum ContractBounds { id: Identifier, document_type_name: String, } = 1, + /// Application authentication permissions. Existing encryption variants retain their wire tags. + Scoped(AuthenticationScope) = 2, // /// this key can only be used within contracts owned by a specified owner // #[serde(rename = "multipleContractsOfSameOwner")] // MultipleContractsOfSameOwner { owner_id: Identifier } = 2, @@ -69,7 +73,7 @@ impl ContractBounds { match self { SingleContract { .. } => 0, SingleContractDocumentType { .. } => 1, - // MultipleContractsOfSameOwner { .. } => 2, + Self::Scoped(_) => 2, } } @@ -77,6 +81,7 @@ impl ContractBounds { match str { "singleContract" => Ok(0), "documentType" => Ok(1), + "scoped" => Ok(2), _ => Err(ProtocolError::DecodingError(String::from( "Expected type to be one of none, singleContract or singleContractDocumentType", ))), @@ -87,16 +92,39 @@ impl ContractBounds { match self { SingleContract { .. } => "singleContract", SingleContractDocumentType { .. } => "documentType", - // MultipleContractsOfSameOwner { .. } => "multipleContractsOfSameOwner", + Self::Scoped(_) => "scoped", } } + /// Every bounded contract and its optional document-type restriction. + /// Unlike the legacy singular accessors, this retains the entire delegation. + pub fn contracts(&self) -> impl Iterator)> { + let single = match self { + Self::SingleContract { id } => Some((id, None)), + Self::SingleContractDocumentType { + id, + document_type_name, + } => Some((id, Some(std::slice::from_ref(document_type_name)))), + Self::Scoped(_) => None, + }; + let scoped = match self { + Self::Scoped(scope) => Some(scope.contracts()), + _ => None, + }; + single.into_iter().chain( + scoped + .into_iter() + .flatten() + .map(|entry| (&entry.id, entry.document_types.as_deref())), + ) + } + /// Gets the identifier - pub fn identifier(&self) -> &Identifier { + pub fn identifier(&self) -> Option<&Identifier> { match self { - SingleContract { id } => id, - SingleContractDocumentType { id, .. } => id, - // MultipleContractsOfSameOwner { owner_id } => owner_id, + SingleContract { id } => Some(id), + SingleContractDocumentType { id, .. } => Some(id), + Self::Scoped(_) => None, } } @@ -108,7 +136,7 @@ impl ContractBounds { document_type_name: document_type, .. } => Some(document_type), - // MultipleContractsOfSameOwner { .. } => None, + Self::Scoped(_) => None, } } // @@ -176,7 +204,7 @@ mod core_tests { assert!(matches!(bounds, ContractBounds::SingleContract { .. })); assert_eq!(bounds.contract_bounds_type(), 0); assert_eq!(bounds.contract_bounds_type_string(), "singleContract"); - assert_eq!(bounds.identifier().as_bytes(), id_bytes.as_slice()); + assert_eq!(bounds.identifier().unwrap().as_bytes(), id_bytes.as_slice()); // document_type is None for SingleContract regardless of what we passed in. assert!(bounds.document_type().is_none()); } @@ -192,7 +220,7 @@ mod core_tests { )); assert_eq!(bounds.contract_bounds_type(), 1); assert_eq!(bounds.contract_bounds_type_string(), "documentType"); - assert_eq!(bounds.identifier().as_bytes(), id_bytes.as_slice()); + assert_eq!(bounds.identifier().unwrap().as_bytes(), id_bytes.as_slice()); assert_eq!(bounds.document_type().map(String::as_str), Some("myDoc")); } diff --git a/packages/rs-dpp/src/identity/identity_public_key/mod.rs b/packages/rs-dpp/src/identity/identity_public_key/mod.rs index c76091ccddc..5989a50f896 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/mod.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/mod.rs @@ -51,7 +51,11 @@ pub type TimestampMillis = u64; Ord, PartialOrd, )] -#[platform_serialize(limit = 2000, unversioned)] //This is not platform versioned automatically +// Bincode's decoding budget includes container allocations, not just wire bytes. +// A valid scope can allocate 16 ContractScopes and 256 Strings in addition to +// its 2 KiB encoding. 16 KiB covers those allocations and the remaining key fields. +// Scope validation retains its separate 2 KiB wire limit. +#[platform_serialize(limit = 16384, unversioned)] // Not automatically platform versioned. #[cfg_attr(feature = "value-conversion", derive(ValueConvertible))] #[serde(tag = "$formatVersion")] pub enum IdentityPublicKey { diff --git a/packages/rs-dpp/src/shielded/mod.rs b/packages/rs-dpp/src/shielded/mod.rs index edf4a0a7de2..6fb496496d1 100644 --- a/packages/rs-dpp/src/shielded/mod.rs +++ b/packages/rs-dpp/src/shielded/mod.rs @@ -24,7 +24,8 @@ pub use compute_minimum_shielded_fee::{ // re-exported (callers use the wrappers; byte-layout tests use the `_v0` impls). pub use sighash::{ compute_platform_sighash, identity_create_from_shielded_extra_sighash_data, - identity_create_from_shielded_extra_sighash_data_v0, shielded_withdrawal_extra_sighash_data, + identity_create_from_shielded_extra_sighash_data_v0, + identity_create_from_shielded_extra_sighash_data_v1, shielded_withdrawal_extra_sighash_data, shielded_withdrawal_extra_sighash_data_v0, unshield_extra_sighash_data, unshield_extra_sighash_data_v0, }; diff --git a/packages/rs-dpp/src/shielded/sighash.rs b/packages/rs-dpp/src/shielded/sighash.rs index 5856a9e5b33..05aeaa36a59 100644 --- a/packages/rs-dpp/src/shielded/sighash.rs +++ b/packages/rs-dpp/src/shielded/sighash.rs @@ -74,7 +74,7 @@ pub fn shielded_withdrawal_extra_sighash_data( platform_version: &PlatformVersion, ) -> Result, ProtocolError> { match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(shielded_withdrawal_extra_sighash_data_v0( + 0 | 1 => Ok(shielded_withdrawal_extra_sighash_data_v0( output_script, unshielding_amount, core_fee_per_byte, @@ -82,7 +82,7 @@ pub fn shielded_withdrawal_extra_sighash_data( )), version => Err(ProtocolError::UnknownVersionMismatch { method: "shielded_withdrawal_extra_sighash_data".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } @@ -117,13 +117,13 @@ pub fn unshield_extra_sighash_data( platform_version: &PlatformVersion, ) -> Result, ProtocolError> { match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(unshield_extra_sighash_data_v0( + 0 | 1 => Ok(unshield_extra_sighash_data_v0( output_address, unshielding_amount, )), version => Err(ProtocolError::UnknownVersionMismatch { method: "unshield_extra_sighash_data".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } @@ -171,15 +171,21 @@ pub fn identity_create_from_shielded_extra_sighash_data( platform_version: &PlatformVersion, ) -> Result, ProtocolError> { match platform_version.dpp.methods.shielded_extra_sighash_data { - 0 => Ok(identity_create_from_shielded_extra_sighash_data_v0( + 0 => identity_create_from_shielded_extra_sighash_data_v0( identity_id, denomination, send_to_address_on_creation_failure, public_keys, - )), + ), + 1 => identity_create_from_shielded_extra_sighash_data_v1( + identity_id, + denomination, + send_to_address_on_creation_failure, + public_keys, + ), version => Err(ProtocolError::UnknownVersionMismatch { method: "identity_create_from_shielded_extra_sighash_data".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } @@ -193,7 +199,7 @@ pub fn identity_create_from_shielded_extra_sighash_data_v0( denomination: u64, send_to_address_on_creation_failure: &PlatformAddress, public_keys: &[IdentityPublicKeyInCreation], -) -> Vec { +) -> Result, ProtocolError> { let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44); data.extend_from_slice(identity_id); data.extend_from_slice(&denomination.to_le_bytes()); @@ -225,6 +231,13 @@ pub fn identity_create_from_shielded_extra_sighash_data_v0( // cannot flip `read_only` or alter `contract_bounds` on an observed transition. data.push(key.read_only() as u8); match key.contract_bounds() { + // This variant was not representable under v0. Reject it without + // changing a single byte of any historical preimage. + Some(ContractBounds::Scoped(_)) => { + return Err(ProtocolError::InvalidKeyContractBoundsError( + "scoped keys require shielded sighash v1".into(), + )) + } None => data.push(0u8), Some(ContractBounds::SingleContract { id }) => { data.push(1u8); @@ -242,7 +255,71 @@ pub fn identity_create_from_shielded_extra_sighash_data_v0( } } } - data + Ok(data) +} + +/// v1 adds a length-prefixed scoped delegation; legacy keys retain their preimages. +pub fn identity_create_from_shielded_extra_sighash_data_v1( + identity_id: &[u8; 32], + denomination: u64, + send_to_address_on_creation_failure: &PlatformAddress, + public_keys: &[IdentityPublicKeyInCreation], +) -> Result, ProtocolError> { + let mut data = Vec::with_capacity(32 + 8 + 21 + 2 + public_keys.len() * 44); + data.extend_from_slice(identity_id); + data.extend_from_slice(&denomination.to_le_bytes()); + // Bind the fallback address (type tag || 20-byte hash) so a relayer cannot redirect the + // failure credit. Mirrors the way `unshield`/`withdrawal` bind their output address. + match send_to_address_on_creation_failure { + PlatformAddress::P2pkh(hash) => { + data.push(0u8); + data.extend_from_slice(hash); + } + PlatformAddress::P2sh(hash) => { + data.push(1u8); + data.extend_from_slice(hash); + } + } + data.extend_from_slice(&(public_keys.len() as u16).to_le_bytes()); + for key in public_keys { + data.extend_from_slice(&key.id().to_le_bytes()); + data.push(key.purpose() as u8); + data.push(key.security_level() as u8); + data.push(key.key_type() as u8); + let key_data = key.data().as_slice(); + data.extend_from_slice(&(key_data.len() as u16).to_le_bytes()); + data.extend_from_slice(key_data); + // Also bind `read_only` and `contract_bounds`. These are state-determining key fields that + // ARE in the transition's signable_bytes, but the per-key proof-of-possession does NOT bind + // them for hash-based key types (which accept an empty signature). Committing them into the + // Orchard binding sighash makes them un-malleable for EVERY key type, so a relayer/proposer + // cannot flip `read_only` or alter `contract_bounds` on an observed transition. + data.push(key.read_only() as u8); + match key.contract_bounds() { + Some(ContractBounds::Scoped(scope)) => { + let bytes = scope.to_bytes()?; + data.push(3u8); + data.extend_from_slice(&(bytes.len() as u16).to_le_bytes()); + data.extend_from_slice(&bytes); + } + None => data.push(0u8), + Some(ContractBounds::SingleContract { id }) => { + data.push(1u8); + data.extend_from_slice(id.as_bytes()); + } + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => { + data.push(2u8); + data.extend_from_slice(id.as_bytes()); + let name = document_type_name.as_bytes(); + data.extend_from_slice(&(name.len() as u16).to_le_bytes()); + data.extend_from_slice(name); + } + } + } + Ok(data) } #[cfg(test)] @@ -306,7 +383,20 @@ mod tests { use super::*; // Pin the v0 preimage directly (see the note in the parent test module). use crate::identity::{KeyType, Purpose, SecurityLevel}; - use crate::shielded::identity_create_from_shielded_extra_sighash_data_v0 as identity_create_from_shielded_extra_sighash_data; + fn identity_create_from_shielded_extra_sighash_data( + id: &[u8; 32], + denomination: u64, + fallback: &PlatformAddress, + keys: &[IdentityPublicKeyInCreation], + ) -> Vec { + super::super::identity_create_from_shielded_extra_sighash_data_v0( + id, + denomination, + fallback, + keys, + ) + .unwrap() + } use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use platform_value::BinaryData; diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 5c048ae84ef..eb116981383 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -805,6 +805,32 @@ impl StateTransition { platform_value::with_value_decode_depth_limit(max_value_depth, || { StateTransition::deserialize_from_bytes(bytes) })?; + // Before activation, old binaries cannot decode the new bounds variant. + // Preserve that unpaid failure before any asset lock or nonce can be consumed. + if platform_version.protocol_version < 14 { + use crate::identity::contract_bounds::ContractBounds; + use crate::state_transition::identity_create_from_addresses_transition::accessors::IdentityCreateFromAddressesTransitionAccessorsV0; + use crate::state_transition::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; + use crate::state_transition::identity_create_transition::accessors::IdentityCreateTransitionAccessorsV0; + use crate::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; + use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; + + let keys = match &state_transition { + Self::IdentityCreate(st) => st.public_keys(), + Self::IdentityCreateFromAddresses(st) => st.public_keys(), + Self::IdentityCreateFromShieldedPool(st) => st.public_keys(), + Self::IdentityUpdate(st) => st.public_keys_to_add(), + _ => &[], + }; + if keys + .iter() + .any(|key| matches!(key.contract_bounds(), Some(ContractBounds::Scoped(_)))) + { + return Err(ProtocolError::PlatformDeserializationError( + "scoped authentication keys are not activated".into(), + )); + } + } #[cfg(all(feature = "state-transitions", feature = "validation"))] { let active_version_range = state_transition.active_version_range(); @@ -1279,6 +1305,43 @@ impl StateTransition { call_method_identity_signed!(self, set_signature_public_key_id, public_key_id) } + /// Check the scope when the signing API receives the identity key metadata. + /// Raw signing primitives cannot check bounds without that metadata. + #[cfg(feature = "state-transition-signing")] + fn verify_identity_key_scope( + &self, + identity_public_key: &IdentityPublicKey, + ) -> Result<(), ProtocolError> { + if let Some(crate::identity::contract_bounds::ContractBounds::Scoped(scope)) = + identity_public_key.contract_bounds() + { + use crate::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + match self { + StateTransition::Batch(batch) + if batch + .transitions_iter() + .all(|transition| scope.allows_transition(transition)) => {} + StateTransition::Batch(_) => { + return Err(ProtocolError::ConsensusError(Box::new( + crate::consensus::signature::ScopedKeyOutOfScopeError::new( + identity_public_key.id(), + ) + .into(), + ))) + } + _ => { + return Err(ProtocolError::ConsensusError(Box::new( + crate::consensus::signature::ScopedKeyNonBatchError::new( + identity_public_key.id(), + ) + .into(), + ))) + } + } + } + Ok(()) + } + #[cfg(feature = "state-transition-signing")] pub async fn sign_external>( &mut self, @@ -1307,6 +1370,7 @@ impl StateTransition { >, options: StateTransitionSigningOptions, ) -> Result<(), ProtocolError> { + self.verify_identity_key_scope(identity_public_key)?; match self { StateTransition::DataContractCreate(st) => { st.verify_public_key_level_and_purpose(identity_public_key, options)?; @@ -1482,6 +1546,7 @@ impl StateTransition { bls: &impl BlsModule, options: StateTransitionSigningOptions, ) -> Result<(), ProtocolError> { + self.verify_identity_key_scope(identity_public_key)?; call_errorable_method_identity_signed!( self, verify_public_key_level_and_purpose, @@ -1973,6 +2038,81 @@ mod tests { // StateTransitionSigningOptions tests // ----------------------------------------------------------------------- + #[cfg(all(feature = "state-transition-signing", feature = "bls-signatures"))] + #[test] + fn should_enforce_scope_before_private_key_signing() { + use crate::consensus::signature::{ScopedKeyNonBatchError, ScopedKeyOutOfScopeError}; + use crate::identity::contract_bounds::authentication_scope::{ + permissions, AuthenticationScope, AuthenticationScopeV0, ContractScope, + }; + use crate::identity::contract_bounds::ContractBounds; + use crate::identity::identity_public_key::v0::IdentityPublicKeyV0; + + let private_key = [1; 32]; + let bls = crate::bls::native_bls::NativeBlsModule; + let scope = AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([2; 32]), + document_types: Some(vec!["preorder".to_string()]), + }], + permissions: permissions::DOCUMENT_DELETE, + expires_at: None, + }; + let mut key = IdentityPublicKeyV0 { + id: 7, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_SECP256K1, + data: get_compressed_public_ec_key(&private_key) + .unwrap() + .to_vec() + .into(), + contract_bounds: Some(ContractBounds::Scoped(AuthenticationScope::V0( + scope.clone(), + ))), + ..Default::default() + }; + sample_batch_st_with_delete() + .sign(&key.clone().into(), &private_key, &bls) + .expect("allowed document delete must sign"); + + let err = sample_transfer_st() + .sign(&key.clone().into(), &private_key, &bls) + .unwrap_err(); + assert!(matches!(err, ProtocolError::ConsensusError(error) + if *error == ScopedKeyNonBatchError::new(key.id).into())); + + for mismatch in ["contract", "document type", "operation"] { + let mut denied = scope.clone(); + match mismatch { + "contract" => denied.contracts[0].id = Identifier::from([3; 32]), + "document type" => denied.contracts[0].document_types = Some(vec!["other".into()]), + "operation" => denied.permissions = permissions::DOCUMENT_CREATE, + _ => unreachable!(), + } + key.contract_bounds = Some(ContractBounds::Scoped(AuthenticationScope::V0(denied))); + let mut transition = sample_batch_st_with_delete(); + let original = transition.clone(); + let err = transition + .sign(&key.clone().into(), &private_key, &bls) + .unwrap_err(); + assert!( + matches!(err, ProtocolError::ConsensusError(error) + if *error == ScopedKeyOutOfScopeError::new(key.id).into()), + "{mismatch}" + ); + assert_eq!( + transition, original, + "rejection must preserve the transition" + ); + } + + key.contract_bounds = None; + sample_batch_st_with_delete() + .sign(&key.into(), &private_key, &bls) + .expect("unscoped keys must still sign"); + } + #[test] fn test_signing_options_default() { let opts = StateTransitionSigningOptions::default(); @@ -2324,6 +2464,86 @@ mod tests { assert_eq!(original, restored); } + #[test] + fn should_reject_scoped_registration_during_decoding_before_activation() { + use crate::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractBounds, ContractScope, + }; + use crate::serialization::PlatformSerializable; + use crate::state_transition::identity_create_from_addresses_transition::v0::IdentityCreateFromAddressesTransitionV0; + use crate::state_transition::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; + use crate::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + + let key = IdentityPublicKeyInCreationV0 { + contract_bounds: Some(ContractBounds::Scoped(AuthenticationScope::V0( + AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([1; 32]), + document_types: None, + }], + permissions: permissions::DOCUMENT_CREATE, + expires_at: None, + }, + ))), + ..Default::default() + }; + let transitions = [ + StateTransition::IdentityCreate(IdentityCreateTransition::V0( + IdentityCreateTransitionV0 { + public_keys: vec![key.clone().into()], + ..Default::default() + }, + )), + StateTransition::IdentityCreateFromAddresses( + IdentityCreateFromAddressesTransition::V0( + IdentityCreateFromAddressesTransitionV0 { + public_keys: vec![key.clone().into()], + ..Default::default() + }, + ), + ), + StateTransition::IdentityCreateFromShieldedPool( + IdentityCreateFromShieldedPoolTransition::V0( + IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![key.clone().into()], + denomination: 0, + actions: vec![], + anchor: [0; 32], + proof: vec![], + binding_signature: [0; 64], + send_to_address_on_creation_failure: Default::default(), + identity_id: Identifier::from([0; 32]), + }, + ), + ), + StateTransition::IdentityUpdate(IdentityUpdateTransition::V0( + IdentityUpdateTransitionV0 { + add_public_keys: vec![key.into()], + ..Default::default() + }, + )), + ]; + for transition in transitions { + let bytes = transition.serialize_to_bytes().unwrap(); + assert!(matches!( + StateTransition::deserialize_from_bytes_in_version( + &bytes, + PlatformVersion::get(13).unwrap() + ), + Err(ProtocolError::PlatformDeserializationError(_)) + )); + assert_eq!( + StateTransition::deserialize_from_bytes_in_version( + &bytes, + PlatformVersion::get(14).unwrap() + ) + .unwrap(), + transition + ); + } + } + #[test] fn test_transaction_id_is_deterministic() { let st = sample_transfer_st(); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs index e002fffb270..34b5cb00d57 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/mod.rs @@ -4,6 +4,7 @@ use crate::ProtocolError; use platform_version::version::PlatformVersion; pub mod v0; +pub mod v1; impl IdentityPublicKeyInCreation { pub fn validate_identity_public_keys_structure( @@ -22,10 +23,15 @@ impl IdentityPublicKeyInCreation { in_create_identity, platform_version, ), + 1 => Self::validate_identity_public_keys_structure_v1( + identity_public_keys_with_witness, + in_create_identity, + platform_version, + ), version => Err(ProtocolError::UnknownVersionMismatch { method: "IdentityPublicKeyInCreation::validate_identity_public_keys_structure" .to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs index 6b02b2e6454..fd2f3fdd29f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v0/mod.rs @@ -37,11 +37,34 @@ lazy_static! { }; } impl IdentityPublicKeyInCreation { + /// New binaries can decode Scoped even during historical replay. Reject only + /// that newly representable input; all historical validation remains unchanged. + pub(super) fn validate_identity_public_keys_structure_v0( + keys: &[IdentityPublicKeyInCreation], + in_create_identity: bool, + version: &PlatformVersion, + ) -> Result { + if keys.iter().any(|key| { + matches!( + key.contract_bounds(), + Some(crate::identity::contract_bounds::ContractBounds::Scoped(_)) + ) + }) { + return Ok(SimpleConsensusValidationResult::new_with_error( + crate::consensus::basic::identity::InvalidAuthenticationScopeError::new( + "scoped authentication keys are not activated".into(), + ) + .into(), + )); + } + Self::validate_identity_public_keys_structure_common(keys, in_create_identity, version) + } + /// This validation will validate the count of new keys, that there are no duplicates either by /// id or by data. This is done before signature and state validation to remove potential /// attack vectors. #[inline(always)] - pub(super) fn validate_identity_public_keys_structure_v0( + pub(super) fn validate_identity_public_keys_structure_common( identity_public_keys_with_witness: &[IdentityPublicKeyInCreation], in_create_identity: bool, platform_version: &PlatformVersion, diff --git a/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs new file mode 100644 index 00000000000..192fe3ea569 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/identity/public_key_in_creation/methods/validate_identity_public_keys_structure/v1/mod.rs @@ -0,0 +1,32 @@ +use crate::consensus::basic::identity::InvalidAuthenticationScopeError; +use crate::identity::{contract_bounds::ContractBounds, Purpose, SecurityLevel}; +use crate::state_transition::public_key_in_creation::{ + accessors::IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreation, +}; +use crate::{validation::SimpleConsensusValidationResult, version::PlatformVersion, ProtocolError}; + +impl IdentityPublicKeyInCreation { + pub(super) fn validate_identity_public_keys_structure_v1( + keys: &[Self], + in_create_identity: bool, + version: &PlatformVersion, + ) -> Result { + for key in keys { + if let Some(ContractBounds::Scoped(scope)) = key.contract_bounds() { + let reason = if key.purpose() != Purpose::AUTHENTICATION + || key.security_level() == SecurityLevel::MASTER + { + Some("scoped keys must be non-MASTER authentication keys".to_owned()) + } else { + scope.validate().err().map(|error| error.to_string()) + }; + if let Some(reason) = reason { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidAuthenticationScopeError::new(reason).into(), + )); + } + } + } + Self::validate_identity_public_keys_structure_common(keys, in_create_identity, version) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs index 2711d97287c..df8da81456a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/check_tx_verification/v0/mod.rs @@ -194,6 +194,7 @@ pub(super) fn state_transition_to_execution_event_for_check_tx_v0<'a, C: CoreRPC let result = if state_transition.validates_signature_based_on_identity_info() { state_transition.validate_identity_signed_state_transition( platform.drive, + platform.state.last_block_info().time_ms, None, &mut state_transition_execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs index 62575b8cfb4..2daf5d145d1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/mod.rs @@ -13,17 +13,20 @@ use drive::grovedb::TransactionArg; pub mod v0; pub mod v1; +pub mod v2; /// Validates the contract bounds attached to each public key in `identity_public_keys_with_witness`. /// /// `epoch` is used by v1+ to bill the underlying grovedb reads to `execution_context`; v0 /// ignores it (v0 didn't bill these reads — pre-PROTOCOL_VERSION_12 behavior is preserved /// verbatim for chain replay). +#[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. pub(crate) fn validate_identity_public_keys_contract_bounds( identity_id: Identifier, identity_public_keys_with_witness: &[IdentityPublicKeyInCreation], drive: &Drive, epoch: &Epoch, + time_ms: u64, transaction: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -55,9 +58,19 @@ pub(crate) fn validate_identity_public_keys_contract_bounds( execution_context, platform_version, ), + 2 => v2::validate_identity_public_keys_contract_bounds_v2( + identity_id, + identity_public_keys_with_witness, + drive, + epoch, + time_ms, + transaction, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "validate_identity_public_keys_contract_bounds".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } @@ -438,14 +451,14 @@ mod tests { } /// Covers the integration this PR is wiring up — that the public dispatcher actually - /// routes to v1 under `PlatformVersion::latest()` (which sets the bounds-validator - /// version field to 1) and that the `epoch` parameter is forwarded through. If the + /// routes legacy bounds through v1 under `PlatformVersion::latest()` (which sets the bounds-validator + /// version field to 2) and that the `epoch` parameter is forwarded through. If the /// dispatcher were accidentally routing to v0 — which has the DECRYPTION-branch bug — /// the assertion below would flip from `is_valid` to invalid. #[test] - fn dispatcher_routes_to_v1_at_latest_platform_version() { + fn dispatcher_preserves_v1_encryption_rules_at_latest_platform_version() { let platform_version = PlatformVersion::latest(); - // Sanity: `latest` should select v1 of the bounds validator. + // Sanity: `latest` should select v2 of the bounds validator. assert_eq!( platform_version .drive_abci @@ -453,8 +466,8 @@ mod tests { .state_transitions .common_validation_methods .validate_identity_public_key_contract_bounds, - 1, - "test premise: latest platform version is expected to select v1; \ + 2, + "test premise: latest platform version is expected to select v2; \ update this test if the version field moves" ); @@ -487,6 +500,7 @@ mod tests { &[key], &platform.drive, &epoch, + 0, None, &mut execution_context, platform_version, @@ -511,4 +525,78 @@ mod tests { billed_count ); } + #[test] + fn should_validate_scoped_registration_against_contracts_and_executing_time() { + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Setters; + let version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let contract = build_contract_with_decryption_only_bounds(version); + platform + .drive + .apply_contract(&contract, BlockInfo::default(), true, None, None, version) + .unwrap(); + for case in [ + "valid", + "expired", + "missing_type", + "missing_contract", + "wrong_purpose", + "master", + ] { + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: if case == "missing_contract" { + Identifier::from([42; 32]) + } else { + contract.id() + }, + document_types: Some(vec![if case == "missing_type" { + "absent".into() + } else { + "note".into() + }]), + }], + permissions: permissions::DOCUMENT_CREATE | permissions::DOCUMENT_TOKEN_PAYMENT, + expires_at: Some(if case == "expired" { 100 } else { 101 }), + }); + let mut key = make_decryption_key_bound_to_doc_type(contract.id(), "note".into()); + key.set_contract_bounds(Some(ContractBounds::Scoped(scope))); + key.set_purpose(if case == "wrong_purpose" { + Purpose::TRANSFER + } else { + Purpose::AUTHENTICATION + }); + key.set_security_level(if case == "master" { + SecurityLevel::MASTER + } else { + SecurityLevel::HIGH + }); + let mut context = + StateTransitionExecutionContext::default_for_platform_version(version).unwrap(); + let result = validate_identity_public_keys_contract_bounds( + Identifier::from([1; 32]), + &[key], + &platform.drive, + &Epoch::new(0).unwrap(), + 100, + None, + &mut context, + version, + ) + .unwrap(); + assert_eq!( + result.is_valid(), + case == "valid", + "{case}: {:?}", + result.errors + ); + } + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs index 03407cf94d0..d9206f54644 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v0/mod.rs @@ -62,6 +62,12 @@ fn validate_identity_public_key_contract_bounds_v0( let purpose = identity_public_key_in_creation.purpose(); if let Some(contract_bounds) = identity_public_key_in_creation.contract_bounds() { match contract_bounds { + ContractBounds::Scoped(_) => Ok(SimpleConsensusValidationResult::new_with_error( + dpp::consensus::basic::identity::InvalidAuthenticationScopeError::new( + "scope is not activated".into(), + ) + .into(), + )), ContractBounds::SingleContract { id: contract_id } => { // we should fetch the contract let contract = drive.get_contract_with_fetch_info( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs index 57a8c428d9d..b6306b927e3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v1/mod.rs @@ -91,6 +91,14 @@ fn validate_identity_public_key_contract_bounds_v1( let contract_id = match contract_bounds { ContractBounds::SingleContract { id } => *id, ContractBounds::SingleContractDocumentType { id, .. } => *id, + ContractBounds::Scoped(_) => { + return Ok(SimpleConsensusValidationResult::new_with_error( + dpp::consensus::basic::identity::InvalidAuthenticationScopeError::new( + "scope is not activated".into(), + ) + .into(), + )) + } }; let outcome = drive.get_system_or_user_contract_with_fee( contract_id.to_buffer(), @@ -110,6 +118,7 @@ fn validate_identity_public_key_contract_bounds_v1( }; match contract_bounds { + ContractBounds::Scoped(_) => unreachable!("rejected above"), ContractBounds::SingleContract { .. } => { let requirements_for_purpose = match purpose { ENCRYPTION => contract.config().requires_identity_encryption_bounded_key(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v2/mod.rs new file mode 100644 index 00000000000..a5a2375742d --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_identity_public_key_contract_bounds/v2/mod.rs @@ -0,0 +1,85 @@ +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use dpp::block::epoch::Epoch; +use dpp::consensus::basic::{ + document::{DataContractNotPresentError, InvalidDocumentTypeError}, + identity::InvalidAuthenticationScopeError, +}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::identifier::Identifier; +use dpp::identity::{contract_bounds::ContractBounds, Purpose, SecurityLevel}; +use dpp::state_transition::public_key_in_creation::{ + accessors::IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreation, +}; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::{drive::Drive, grovedb::TransactionArg}; + +/// v2 adds authentication delegations; encryption/decryption use unchanged v1 rules. +#[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. +pub(super) fn validate_identity_public_keys_contract_bounds_v2( + identity_id: Identifier, + keys: &[IdentityPublicKeyInCreation], + drive: &Drive, + epoch: &Epoch, + time_ms: u64, + transaction: TransactionArg, + context: &mut StateTransitionExecutionContext, + version: &PlatformVersion, +) -> Result { + let mut result = SimpleConsensusValidationResult::default(); + for key in keys { + let Some(ContractBounds::Scoped(scope)) = key.contract_bounds() else { + result.add_errors( + super::v1::validate_identity_public_keys_contract_bounds_v1( + identity_id, + std::slice::from_ref(key), + drive, + epoch, + transaction, + context, + version, + )? + .errors, + ); + continue; + }; + if key.purpose() != Purpose::AUTHENTICATION + || key.security_level() == SecurityLevel::MASTER + || scope.validate().is_err() + || scope.is_expired(time_ms) + { + result.add_error(InvalidAuthenticationScopeError::new( + "scope must be valid, unexpired and attached to a non-MASTER authentication key" + .into(), + )); + continue; + } + for entry in scope.contracts() { + let outcome = drive.get_system_or_user_contract_with_fee( + entry.id.to_buffer(), + epoch, + transaction, + version, + )?; + if let Some(fee) = outcome.fee() { + context.add_operation(ValidationOperation::PrecalculatedOperation(fee.clone())); + } + let Some(contract) = outcome.contract() else { + result.add_error(DataContractNotPresentError::new(entry.id)); + continue; + }; + if let Some(names) = &entry.document_types { + for name in names { + if contract.document_type_optional_for_name(name).is_none() { + result.add_error(InvalidDocumentTypeError::new(name.clone(), entry.id)); + } + } + } + } + } + Ok(result) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs index 6e35b4361d9..7fe2ef877d9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/mod.rs @@ -10,11 +10,15 @@ use crate::execution::types::state_transition_execution_context::StateTransition use crate::execution::validation::state_transition::common::validate_state_transition_identity_signed::v0::ValidateStateTransitionIdentitySignatureV0; pub mod v0; +mod v1; +use v1::ValidateStateTransitionIdentitySignatureV1; pub trait ValidateStateTransitionIdentitySignature { + #[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. fn validate_state_transition_identity_signed( &self, drive: &Drive, + time_ms: u64, request_balance: bool, request_revision: bool, transaction: TransactionArg, @@ -27,6 +31,7 @@ impl ValidateStateTransitionIdentitySignature for StateTransition { fn validate_state_transition_identity_signed( &self, drive: &Drive, + time_ms: u64, request_balance: bool, request_revision: bool, transaction: TransactionArg, @@ -48,9 +53,18 @@ impl ValidateStateTransitionIdentitySignature for StateTransition { execution_context, platform_version, ), + 1 => self.validate_state_transition_identity_signed_v1( + drive, + time_ms, + request_balance, + request_revision, + transaction, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "StateTransition::validate_state_transition_identity_signature".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs new file mode 100644 index 00000000000..c1ccfb9678e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_state_transition_identity_signed/v1/mod.rs @@ -0,0 +1,63 @@ +use dpp::identity::PartialIdentity; +use dpp::state_transition::StateTransition; +use dpp::validation::ConsensusValidationResult; +use dpp::version::{PlatformVersion}; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::common::validate_state_transition_identity_signed::v0::ValidateStateTransitionIdentitySignatureV0; + +pub(super) trait ValidateStateTransitionIdentitySignatureV1 { + #[allow(clippy::too_many_arguments)] // Keep explicit versioned validation inputs. + fn validate_state_transition_identity_signed_v1( + &self, + drive: &Drive, + time_ms: u64, + request_balance: bool, + request_revision: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} +impl ValidateStateTransitionIdentitySignatureV1 for StateTransition { + fn validate_state_transition_identity_signed_v1( + &self, + drive: &Drive, + time_ms: u64, + request_balance: bool, + request_revision: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + use dpp::identity::contract_bounds::ContractBounds; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let result = self.validate_state_transition_identity_signed_v0( + drive, + request_balance, + request_revision, + transaction, + execution_context, + platform_version, + )?; + if let Some(identity) = result.data.as_ref().filter(|_| result.is_valid()) { + for key in identity.loaded_public_keys.values() { + if let Some(ContractBounds::Scoped(scope)) = key.contract_bounds() { + if scope.is_expired(time_ms) { + return Ok(ConsensusValidationResult::new_with_error( + dpp::consensus::signature::ScopedKeyExpiredError::new(key.id()).into(), + )); + } + if !matches!(self, StateTransition::Batch(_)) { + return Ok(ConsensusValidationResult::new_with_error( + dpp::consensus::signature::ScopedKeyNonBatchError::new(key.id()).into(), + )); + } + } + } + } + Ok(result) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs index bdf5da140d3..00cf466ebda 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/identity_based_signature.rs @@ -30,6 +30,7 @@ pub(crate) trait StateTransitionIdentityBasedSignatureValidationV0 { fn validate_identity_signed_state_transition( &self, drive: &Drive, + time_ms: u64, tx: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -55,6 +56,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { fn validate_identity_signed_state_transition( &self, drive: &Drive, + time_ms: u64, tx: TransactionArg, execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, @@ -68,6 +70,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { //Basic signature verification Ok(self.validate_state_transition_identity_signed( drive, + time_ms, true, false, tx, @@ -79,6 +82,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { let mut consensus_validation_result = self .validate_state_transition_identity_signed( drive, + time_ms, true, false, tx, @@ -102,6 +106,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { //Basic signature verification Ok(self.validate_state_transition_identity_signed( drive, + time_ms, true, true, tx, @@ -117,6 +122,7 @@ impl StateTransitionIdentityBasedSignatureValidationV0 for StateTransition { Ok(self.validate_state_transition_identity_signed( drive, + time_ms, false, false, tx, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs index 6d881a6d248..c8d9a62df47 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/state.rs @@ -97,6 +97,7 @@ impl StateTransitionStateValidation for StateTransition { st.validate_state_for_identity_create_transition( action, platform, + block_info, execution_context, tx, ) @@ -163,6 +164,7 @@ impl StateTransitionStateValidation for StateTransition { st.validate_state_for_identity_create_from_addresses_transition( action, platform, + block_info, execution_context, tx, ) @@ -247,6 +249,7 @@ impl StateTransitionStateValidation for StateTransition { st.validate_state_for_identity_create_from_shielded_pool_transition( action, platform, + block_info, execution_context, tx, ) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs index eb5ca95d0d0..02c9ec024cd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs @@ -59,6 +59,7 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( let result = if state_transition.validates_signature_based_on_identity_info() { state_transition.validate_identity_signed_state_transition( platform.drive, + block_info.time_ms, transaction, &mut state_transition_execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs index 9a1925de7fc..008be12cc67 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/mod.rs @@ -1 +1,2 @@ pub(crate) mod v0; +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs new file mode 100644 index 00000000000..63f19d80971 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/advanced_structure/v1/mod.rs @@ -0,0 +1,292 @@ +use crate::error::Error; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::document::InvalidDocumentTransitionIdError; +use dpp::consensus::signature::{InvalidSignaturePublicKeySecurityLevelError, SignatureError}; +use dpp::dashcore::Network; +use dpp::document::Document; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::PartialIdentity; +use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; +use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::{StateTransitionHasUserFeeIncrease, StateTransitionIdentitySigned, StateTransitionOwned}; +use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; +use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; +use dpp::validation::ConsensusValidationResult; + +use dpp::version::PlatformVersion; + +use drive::state_transition_action::batch::BatchTransitionAction; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_delete_transition_action::DocumentDeleteTransitionActionValidation; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_index_only_delete_transition_action::DocumentIndexOnlyDeleteTransitionActionValidation; +use crate::execution::validation::state_transition::state_transitions::batch::action_validation::document::document_create_transition_action::DocumentCreateTransitionActionValidation; +use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; +use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; +use drive::state_transition_action::batch::batched_transition::document_transition::document_delete_transition_action::v0::DocumentDeleteTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_index_only_delete_transition_action::v0::DocumentIndexOnlyDeleteTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::DocumentReplaceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::DocumentTransferTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::DocumentUpdatePriceTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; +use drive::state_transition_action::StateTransitionAction; +use drive::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; +use crate::error::execution::ExecutionError; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0}; +use crate::execution::validation::state_transition::batch::action_validation::document::document_purchase_transition_action::DocumentPurchaseTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::document::document_transfer_transition_action::DocumentTransferTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::document::document_update_price_transition_action::DocumentUpdatePriceTransitionActionValidation; +use crate::execution::validation::state_transition::batch::action_validation::token::token_base_transition_action::TokenBaseTransitionActionValidation; + +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait DocumentsBatchStateTransitionStructureValidationV1 +{ + fn validate_advanced_structure_from_state_v1( + &self, + block_info: &BlockInfo, + network: Network, + action: &BatchTransitionAction, + identity: &PartialIdentity, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl DocumentsBatchStateTransitionStructureValidationV1 for BatchTransition { + fn validate_advanced_structure_from_state_v1( + &self, + block_info: &BlockInfo, + network: Network, + action: &BatchTransitionAction, + identity: &PartialIdentity, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let security_levels = action.combined_security_level_requirement()?; + + let signing_key = identity.loaded_public_keys.get(&self.signature_public_key_id()).ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution("the key must exist for advanced structure validation as we already fetched it during signature validation")))?; + + if !security_levels.contains(&signing_key.security_level()) { + // We only need to bump the first identity data contract nonce as that will make a replay + // attack not possible + + let first_transition = self.first_transition().ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution("There must be at least one state transition as this is already verified in basic validation")))?; + + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_batched_transition_ref( + first_transition, + self.owner_id(), + self.user_fee_increase(), + ), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + vec![SignatureError::InvalidSignaturePublicKeySecurityLevelError( + InvalidSignaturePublicKeySecurityLevelError::new( + signing_key.security_level(), + security_levels, + ), + ) + .into()], + )); + } + + if let Some(dpp::identity::contract_bounds::ContractBounds::Scoped(scope)) = + signing_key.contract_bounds() + { + use dpp::identity::contract_bounds::authentication_scope::permissions; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; + let unauthorized = self + .transitions_iter() + .any(|member| !scope.allows_transition(member)); + let unauthorized_payment = !scope.allows(permissions::DOCUMENT_TOKEN_PAYMENT) + && action.transitions().iter().any(|member| { + matches!(member, BatchedTransitionAction::DocumentAction(doc) + if doc.base().token_cost().is_some_and(|(_, _, amount)| amount > 0)) + }); + if unauthorized || unauthorized_payment { + let first = self.first_transition().ok_or(Error::Execution( + ExecutionError::CorruptedCodeExecution("empty validated batch"), + ))?; + let bump = BumpIdentityDataContractNonceAction::from_batched_transition_ref( + first, + self.owner_id(), + self.user_fee_increase(), + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::BumpIdentityDataContractNonceAction(bump), + vec![dpp::consensus::signature::ScopedKeyOutOfScopeError::new( + signing_key.id(), + ) + .into()], + )); + } + } + + // We should validate that all newly created documents have valid ids + for transition in self.transitions_iter() { + if let BatchedTransitionRef::Document(DocumentTransition::Create(create_transition)) = + transition + { + // Validate the ID + let generated_document_id = Document::generate_document_id_v0( + create_transition.base().data_contract_id_ref(), + &self.owner_id(), + create_transition.base().document_type_name(), + &create_transition.entropy(), + ); + + // This hash will take 2 blocks (128 bytes) + execution_context.add_operation(ValidationOperation::DoubleSha256(2)); + + let id = create_transition.base().id(); + if generated_document_id != id { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + create_transition.base(), + self.owner_id(), + self.user_fee_increase(), + ), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + vec![ + InvalidDocumentTransitionIdError::new(generated_document_id, id).into(), + ], + )); + } + } + } + + // Next we need to validate the structure of all actions (this means with the data contract) + for transition in action.transitions() { + match transition { + BatchedTransitionAction::DocumentAction(document_action) => match document_action { + DocumentTransitionAction::CreateAction(create_action) => { + let result = create_action.validate_structure( + identity.id, + block_info, + network, + platform_version, + )?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(document_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::ReplaceAction(replace_action) => { + let result = replace_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(replace_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::DeleteAction(delete_action) => { + let result = delete_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(delete_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::TransferAction(transfer_action) => { + let result = transfer_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(transfer_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::UpdatePriceAction(update_price_action) => { + let result = update_price_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(update_price_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::PurchaseAction(purchase_action) => { + let result = purchase_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(purchase_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + DocumentTransitionAction::IndexOnlyDeleteAction(index_only_delete_action) => { + let result = + index_only_delete_action.validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition_action(index_only_delete_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + }, + BatchedTransitionAction::TokenAction(token_transition_action) => { + // token actions only need to do advanced structure validation on the base action + let result = token_transition_action + .base() + .validate_structure(platform_version)?; + if !result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityDataContractNonceAction( + BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition_action(token_transition_action.base(), self.owner_id(), self.user_fee_increase()), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + result.errors, + )); + } + } + BatchedTransitionAction::BumpIdentityDataContractNonce(_) => { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "we should not have a bump identity contract nonce at this stage", + ))); + } + } + } + Ok(ConsensusValidationResult::new()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs index c1fef559bd1..527ce9b6115 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs @@ -1,3 +1,4 @@ +use advanced_structure::v1::DocumentsBatchStateTransitionStructureValidationV1; mod action_validation; mod advanced_structure; mod data_triggers; @@ -175,7 +176,7 @@ impl StateTransitionStructureKnownInStateValidationV0 for BatchTransition { .batch_state_transition .advanced_structure { - 0 => { + 0 | 1 => { let identity = identity.ok_or(Error::Execution(ExecutionError::CorruptedCodeExecution( "The identity must be known on advanced structure validation", @@ -186,18 +187,36 @@ impl StateTransitionStructureKnownInStateValidationV0 for BatchTransition { "action must be a documents batch transition action", ))); }; - self.validate_advanced_structure_from_state_v0( - block_info, - network, - documents_batch_transition_action, - identity, - execution_context, - platform_version, - ) + if platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .advanced_structure + == 1 + { + self.validate_advanced_structure_from_state_v1( + block_info, + network, + documents_batch_transition_action, + identity, + execution_context, + platform_version, + ) + } else { + self.validate_advanced_structure_from_state_v0( + block_info, + network, + documents_batch_transition_action, + identity, + execution_context, + platform_version, + ) + } } version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "documents batch transition: advanced structure from state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs index 41ddaad8029..812742bc8e3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/mod.rs @@ -64,3 +64,5 @@ use drive::util::storage_flags::StorageFlags; use rand::prelude::StdRng; use rand::Rng; use rand::SeedableRng; + +mod scoped_auth; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/scoped_auth.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/scoped_auth.rs new file mode 100644 index 00000000000..406334e1fda --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/scoped_auth.rs @@ -0,0 +1,611 @@ +use super::*; +use crate::execution::validation::state_transition::tests::setup_identity_without_adding_it; +use dpp::consensus::codes::ErrorWithCode; +use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, ContractBounds, + ContractScope, +}; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{accessors::IdentitySettersV0, IdentityPublicKey}; +use dpp::state_transition::batch_transition::methods::v1::DocumentsBatchTransitionMethodsV1; + +/// Sign with the original unbounded key metadata to deliberately bypass SDK +/// preflight; validators must enforce the scoped key stored in Drive. +#[tokio::test] +async fn should_enforce_scoped_auth_in_execution_and_preserve_paid_failure_nonces() { + for case in [ + "allowed", + "wrong_contract", + "wrong_action", + "expired", + "disabled", + "wrong_type", + "multi_contract", + "mixed", + ] { + let version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let dpns = platform + .drive + .cache + .system_data_contracts + .load_dpns(version) + .unwrap(); + let mut contracts = vec![ContractScope { + id: if case == "wrong_contract" { + dpns.id() + } else { + dashpay.id() + }, + document_types: if case == "wrong_type" { + Some(vec!["contactRequest".into()]) + } else if case == "mixed" { + Some(vec!["profile".into()]) + } else { + None + }, + }]; + if case == "multi_contract" { + contracts.push(ContractScope { + id: dpns.id(), + document_types: None, + }); + contracts.sort_by_key(|c| c.id); + } + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts, + permissions: if case == "wrong_action" { + permissions::DOCUMENT_DELETE + } else { + permissions::DOCUMENT_CREATE + }, + expires_at: Some(if case == "expired" { 100 } else { 101 }), + }); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::Scoped(scope)); + if case == "disabled" { + key.disabled_at = Some(99); + } + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + let state = platform.state.load(); + let profile = dashpay.document_type_for_name("profile").unwrap(); + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = profile + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + version, + ) + .unwrap(); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + let mut batch = BatchTransition::new_document_creation_transition_from_document( + document, + profile, + entropy.0, + &signing_key, + 2, + 0, + None, + &signer, + version, + None, + ) + .await + .unwrap(); + if case == "mixed" { + use dpp::state_transition::batch_transition::batched_transition::{ + document_transition::DocumentTransitionV0Methods, BatchedTransition, + }; + use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; + use dpp::state_transition::StateTransition; + let StateTransition::Batch(BatchTransition::V1(ref mut inner)) = batch else { + panic!("expected v1 batch") + }; + let mut second = inner.transitions[0].clone(); + let BatchedTransition::Document(ref mut doc) = second else { + unreachable!() + }; + doc.base_mut() + .set_document_type_name("contactRequest".into()); + doc.base_mut() + .set_id(dpp::prelude::Identifier::from([8; 32])); + inner.transitions.push(second); + batch + .sign_external( + &signing_key, + &signer, + Some(|_, _| Ok(dpp::identity::SecurityLevel::HIGH)), + ) + .await + .unwrap(); + } + let bytes = batch.serialize_to_bytes().unwrap(); + let tx = platform.drive.grove.start_transaction(); + let block_info = BlockInfo { + time_ms: 100, + ..Default::default() + }; + let result = platform + .platform + .process_raw_state_transitions( + &vec![bytes.clone()], + &state, + &block_info, + &tx, + version, + false, + None, + ) + .unwrap(); + let execution = &result.execution_results()[0]; + match case { + // Protocol 14 still limits batches to one member; mixed batches must + // fail at basic validation before any scope checks or fees. + "mixed" => { + assert!( + matches!(execution, StateTransitionExecutionResult::UnpaidConsensusError(error) if error.code() == 10412), + "{execution:?}" + ); + assert_eq!( + platform + .drive + .fetch_identity_contract_nonce( + identity.id().to_buffer(), + dashpay.id().to_buffer(), + true, + Some(&tx), + version + ) + .unwrap(), + None + ); + assert_eq!( + platform + .drive + .fetch_identity_balance(identity.id().to_buffer(), Some(&tx), version) + .unwrap(), + Some(identity.balance()) + ); + } + "allowed" | "multi_contract" => assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{case}: {execution:?}" + ), + "disabled" => assert!( + matches!( + execution, + StateTransitionExecutionResult::UnpaidConsensusError(_) + ), + "{execution:?}" + ), + "expired" => assert!( + matches!(execution, StateTransitionExecutionResult::UnpaidConsensusError(error) if error.code() == 20014), + "{execution:?}" + ), + _ => { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20015), + "{case}: {execution:?}" + ); + assert_eq!( + platform + .drive + .fetch_identity_contract_nonce( + identity.id().to_buffer(), + dashpay.id().to_buffer(), + true, + Some(&tx), + version + ) + .unwrap(), + Some((1u64 << 40) | 2) + ); + let balance = platform + .drive + .fetch_identity_balance(identity.id().to_buffer(), Some(&tx), version) + .unwrap() + .unwrap(); + assert!( + balance < identity.balance(), + "invalid batch must pay validation fees" + ); + let replay = platform + .platform + .process_raw_state_transitions( + &vec![bytes], + &state, + &block_info, + &tx, + version, + false, + None, + ) + .unwrap(); + assert!( + matches!( + &replay.execution_results()[0], + StateTransitionExecutionResult::UnpaidConsensusError(_) + ), + "replay must not charge twice" + ); + } + } + } +} + +#[tokio::test] +async fn should_require_token_payment_permission_including_external_fee_tokens() { + use crate::execution::validation::state_transition::tests::{ + create_card_game_external_token_contract_with_owner_identity, + create_token_contract_with_owner_identity, + }; + use dpp::data_contract::TokenConfiguration; + use dpp::tokens::{ + gas_fees_paid_by::GasFeesPaidBy, + token_payment_info::{v0::TokenPaymentInfoV0, TokenPaymentInfo}, + }; + for allow_payment in [false, true] { + let version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (owner, _, _) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + let (token_contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + None::, + None, + None, + None, + version, + ); + let contract = create_card_game_external_token_contract_with_owner_identity( + &mut platform, + token_contract.id(), + 0, + 5, + GasFeesPaidBy::DocumentOwner, + owner.id(), + version, + ); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(234, dash_to_credits!(0.1)); + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + // Intentionally does not include the external token's issuing contract. + contracts: vec![ContractScope { + id: contract.id(), + document_types: Some(vec!["card".into()]), + }], + permissions: permissions::DOCUMENT_CREATE + | if allow_payment { + permissions::DOCUMENT_TOKEN_PAYMENT + } else { + 0 + }, + expires_at: None, + }); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::Scoped(scope)); + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + add_tokens_to_identity(&platform, token_id.into(), identity.id(), 15); + let card = contract.document_type_for_name("card").unwrap(); + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = card + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + version, + ) + .unwrap(); + document.set("attack", 4.into()); + document.set("defense", 7.into()); + let batch = BatchTransition::new_document_creation_transition_from_document( + document, + card, + entropy.0, + &signing_key, + 2, + 0, + Some(TokenPaymentInfo::V0(TokenPaymentInfoV0 { + payment_token_contract_id: Some(token_contract.id()), + token_contract_position: 0, + minimum_token_cost: None, + maximum_token_cost: Some(5), + gas_fees_paid_by: GasFeesPaidBy::DocumentOwner, + })), + &signer, + version, + None, + ) + .await + .unwrap(); + let tx = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![batch.serialize_to_bytes().unwrap()], + &state, + &BlockInfo::default(), + &tx, + version, + false, + None, + ) + .unwrap(); + let execution = &result.execution_results()[0]; + if allow_payment { + assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{execution:?}" + ); + } else { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20015), + "{execution:?}" + ); + } + let remaining = platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + Some(&tx), + version, + ) + .unwrap(); + assert_eq!(remaining, Some(if allow_payment { 10 } else { 15 })); + } +} + +#[tokio::test] +async fn should_reject_non_batch_use_even_with_all_scope_permissions() { + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::state_transition::data_contract_create_transition::{ + methods::DataContractCreateTransitionMethodsV0, DataContractCreateTransition, + }; + let version = PlatformVersion::latest(); + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::Scoped(AuthenticationScope::V0( + AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: dashpay.id(), + document_types: None, + }], + permissions: permissions::ALL, + expires_at: None, + }, + ))); + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + let mut contract = dashpay.as_ref().clone(); + contract.set_owner_id(identity.id()); + let mut signer_identity = identity.clone(); + signer_identity.add_public_key(signing_key.clone()); + let transition = DataContractCreateTransition::new_from_data_contract( + contract, + 1, + &signer_identity.into_partial_identity_info(), + signing_key.id(), + &signer, + version, + None, + ) + .await + .unwrap(); + let tx = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![transition.serialize_to_bytes().unwrap()], + &state, + &BlockInfo::default(), + &tx, + version, + false, + None, + ) + .unwrap(); + assert!( + matches!(&result.execution_results()[0], StateTransitionExecutionResult::UnpaidConsensusError(error) if error.code() == 20013), + "{:?}", + result.execution_results() + ); + assert_eq!( + platform + .drive + .fetch_identity_balance(identity.id().to_buffer(), Some(&tx), version) + .unwrap(), + Some(identity.balance()) + ); +} + +#[tokio::test] +async fn should_distinguish_scoped_document_token_fees_from_standalone_token_transfers() { + use crate::execution::validation::state_transition::tests::create_token_contract_with_owner_identity; + use dpp::data_contract::TokenConfiguration; + for allow_transfer in [false, true] { + let version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (owner, _, _) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + let (contract, token_id) = create_token_contract_with_owner_identity( + &mut platform, + owner.id(), + None::, + None, + None, + None, + version, + ); + let (mut identity, signer, signing_key) = + setup_identity_without_adding_it(234, dash_to_credits!(0.1)); + let mut stored_key = signing_key.clone(); + let IdentityPublicKey::V0(ref mut key) = stored_key; + key.contract_bounds = Some(ContractBounds::Scoped(AuthenticationScope::V0( + AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: contract.id(), + document_types: None, + }], + permissions: permissions::DOCUMENT_TOKEN_PAYMENT + | if allow_transfer { + permissions::TOKEN_TRANSFER + } else { + 0 + }, + expires_at: None, + }, + ))); + identity.add_public_key(stored_key); + platform + .drive + .add_new_identity( + identity.clone(), + false, + &BlockInfo::default(), + true, + None, + version, + ) + .unwrap(); + add_tokens_to_identity(&platform, token_id.into(), identity.id(), 15); + let batch = BatchTransition::new_token_transfer_transition( + token_id, + identity.id(), + contract.id(), + 0, + 5, + owner.id(), + None, + None, + None, + &signing_key, + 2, + 0, + &signer, + version, + None, + ) + .await + .unwrap(); + let tx = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![batch.serialize_to_bytes().unwrap()], + &state, + &BlockInfo::default(), + &tx, + version, + false, + None, + ) + .unwrap(); + let execution = &result.execution_results()[0]; + if allow_transfer { + assert!( + matches!( + execution, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ), + "{execution:?}" + ); + } else { + assert!( + matches!(execution, StateTransitionExecutionResult::PaidConsensusError { error, .. } if error.code() == 20015), + "{execution:?}" + ); + } + assert_eq!( + platform + .drive + .fetch_identity_token_balance( + token_id.to_buffer(), + identity.id().to_buffer(), + Some(&tx), + version + ) + .unwrap(), + Some(if allow_transfer { 10 } else { 15 }) + ); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs index a15e1695c74..b5baac3b743 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/mod.rs @@ -10,6 +10,7 @@ use crate::error::execution::ExecutionError; use crate::execution::validation::state_transition::identity_create::basic_structure::v0::IdentityCreateStateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::identity_create::state::v0::IdentityCreateStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_create::state::v1::IdentityCreateStateTransitionStateValidationV1; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; @@ -163,6 +164,7 @@ pub trait StateTransitionStateValidationForIdentityCreateTransitionV0 { &self, action: IdentityCreateTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -173,6 +175,7 @@ impl StateTransitionStateValidationForIdentityCreateTransitionV0 for IdentityCre &self, action: IdentityCreateTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -185,9 +188,17 @@ impl StateTransitionStateValidationForIdentityCreateTransitionV0 for IdentityCre .state { 0 => self.validate_state_v0(platform, action, execution_context, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + action, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -198,6 +209,7 @@ impl StateTransitionStateValidationForIdentityCreateTransitionV0 for IdentityCre mod tests { use crate::config::{PlatformConfig, PlatformTestConfig}; use crate::test::helpers::setup::TestPlatformBuilder; + use assert_matches::assert_matches; use dpp::block::block_info::BlockInfo; use dpp::dashcore::{Network, PrivateKey}; use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; @@ -446,6 +458,205 @@ mod tests { assert_eq!(identity_balance, 99913867460); } + #[tokio::test] + async fn should_create_identity_with_scoped_authentication_key() { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + for (protocol, bounds_kind) in [(13, 0), (13, 1), (13, 2), (14, 0)] { + let platform_version = PlatformVersion::get(protocol).unwrap(); + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + + let platform = TestPlatformBuilder::new() + .with_config(platform_config) + .with_initial_protocol_version(protocol) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + + let mut signer = SimpleSigner::default(); + + let mut rng = StdRng::seed_from_u64(567); + + let (master_key, master_private_key) = + IdentityPublicKey::random_ecdsa_master_authentication_key( + 0, + Some(58), + platform_version, + ) + .expect("expected to get key pair"); + + signer.add_identity_public_key(master_key.clone(), master_private_key); + + let (mut key, private_key) = + IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + 1, + Some(999), + platform_version, + ) + .expect("expected to get key pair"); + + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractBounds, ContractScope, + }; + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version) + .unwrap(); + let scoped_bounds = + ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: dashpay.id(), + document_types: Some(vec!["profile".into()]), + }], + permissions: permissions::DOCUMENT_CREATE, + expires_at: Some(100), + })); + let bounds = match bounds_kind { + 0 => scoped_bounds, + 1 => ContractBounds::SingleContract { id: dashpay.id() }, + _ => ContractBounds::SingleContractDocumentType { + id: dashpay.id(), + document_type_name: "profile".into(), + }, + }; + let IdentityPublicKey::V0(ref mut key_v0) = key; + key_v0.contract_bounds = Some(bounds.clone()); + signer.add_identity_public_key(key.clone(), private_key); + + let (_, pk) = ECDSA_SECP256K1 + .random_public_and_private_key_data(&mut rng, platform_version) + .unwrap(); + + let asset_lock_proof = instant_asset_lock_proof_fixture( + Some(PrivateKey::from_byte_array(&pk, Network::Testnet).unwrap()), + None, + ); + + let identifier = asset_lock_proof + .create_identifier() + .expect("expected an identifier"); + + let identity: Identity = IdentityV0 { + id: identifier, + public_keys: BTreeMap::from([(0, master_key.clone()), (1, key.clone())]), + balance: 1000000000, + revision: 0, + } + .into(); + + let identity_create_transition: StateTransition = + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( + &identity, + asset_lock_proof, + pk.as_slice(), + &signer, + &NativeBlsModule, + 0, + platform_version, + ) + .await + .expect("expected an identity create transition"); + + let identity_create_serialized_transition = identity_create_transition + .serialize_to_bytes() + .expect("serialized state transition"); + + let before = platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(); + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![identity_create_serialized_transition.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + if protocol == 13 { + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + if bounds_kind == 0 { + assert_eq!(processing_result.invalid_unpaid_count(), 1); + assert_eq!(processing_result.invalid_paid_count(), 0); + assert_eq!( + platform + .drive + .grove + .root_hash(Some(&transaction), &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + before + ); + } else { + assert_matches!(processing_result.execution_results().as_slice(), [StateTransitionExecutionResult::InternalError(error)] if error.contains("identity key bounds error: purpose not available for key bounds")); + } + assert_eq!( + platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + before + ); + continue; + } + assert_eq!(processing_result.valid_count(), 1); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + let identity_balance = platform + .drive + .fetch_identity_balance(identity.id().into_buffer(), None, platform_version) + .expect("expected to get identity balance") + .expect("expected there to be an identity balance for this identity"); + + assert!(identity_balance > 0); + use drive::drive::identity::key::fetch::IdentityKeysRequest; + let fetched = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest::new_specific_key_query(&identity.id().to_buffer(), 1), + None, + platform_version, + ) + .unwrap() + .unwrap(); + assert_eq!( + fetched + .loaded_public_keys + .get(&1) + .unwrap() + .contract_bounds(), + Some(&bounds) + ); + } + } + #[tokio::test] async fn test_identity_create_asset_lock_reuse_after_issue_first_protocol_version() { let platform_version = PlatformVersion::first(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs new file mode 100644 index 00000000000..5eba2932946 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create/state/v1/mod.rs @@ -0,0 +1,115 @@ +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use dpp::block::block_info::BlockInfo; +use crate::error::Error; +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; + +use dpp::consensus::state::identity::IdentityAlreadyExistsError; + +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::identity_create_transition::accessors::IdentityCreateTransitionAccessorsV0; +use dpp::ProtocolError; + +use dpp::state_transition::identity_create_transition::IdentityCreateTransition; +use dpp::version::PlatformVersion; +use drive::state_transition_action::identity::identity_create::IdentityCreateTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::system::partially_use_asset_lock_action::PartiallyUseAssetLockAction; + +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create) trait IdentityCreateStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityCreateStateTransitionStateValidationV1 for IdentityCreateTransition { + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + + let identity_id = self.identity_id(); + let balance = + drive.fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)?; + + // Balance is here to check if the identity does already exist + if balance.is_some() { + // Since the id comes from the state transition this should never be reachable + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id.to_owned()).into(), + )); + } + + // Now we should check the state of added keys to make sure there aren't any that already exist + let mut key_state_validation_result = + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys(), + drive, + execution_context, + transaction, + platform_version, + )?; + + key_state_validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + identity_id, + self.public_keys(), + drive, + &block_info.epoch, + block_info.time_ms, + transaction, + execution_context, + platform_version, + )? + .errors, + ); + + if key_state_validation_result.is_valid() { + // We just pass the action that was given to us + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateAction(action), + )) + } else { + // It's not valid, we need to give back the action that partially uses the asset lock + + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present; + + let used_credits = penalty + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .ok_or(ProtocolError::Overflow("processing fee overflow error"))?; + + let bump_action = PartiallyUseAssetLockAction::from_identity_create_transition_action( + action, + used_credits, + ); + Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action.into(), + key_state_validation_result.errors, + )) + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs index 90d7f68499d..48b4c87f645 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/mod.rs @@ -14,6 +14,7 @@ use std::collections::BTreeMap; use crate::execution::validation::state_transition::identity_create_from_addresses::basic_structure::v0::IdentityCreateFromAddressesStateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::identity_create_from_addresses::state::v0::IdentityCreateFromAddressesStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_create_from_addresses::state::v1::IdentityCreateFromAddressesStateTransitionStateValidationV1; use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; use crate::platform_types::platform::PlatformRef; @@ -155,6 +156,7 @@ pub trait StateTransitionStateValidationForIdentityCreateFromAddressesTransition &self, action: IdentityCreateFromAddressesTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -167,6 +169,7 @@ impl StateTransitionStateValidationForIdentityCreateFromAddressesTransitionV0 &self, action: IdentityCreateFromAddressesTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -179,9 +182,17 @@ impl StateTransitionStateValidationForIdentityCreateFromAddressesTransitionV0 .state { 0 => self.validate_state_v0(platform, action, execution_context, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + action, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create from addresses transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs new file mode 100644 index 00000000000..649dfd7ab8c --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/state/v1/mod.rs @@ -0,0 +1,117 @@ +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use dpp::block::block_info::BlockInfo; +use crate::error::Error; +use crate::platform_types::platform::PlatformRef; + +use dpp::consensus::state::identity::IdentityAlreadyExistsError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::identity_create_from_addresses_transition::accessors::IdentityCreateFromAddressesTransitionAccessorsV0; +use dpp::ProtocolError; + +use dpp::state_transition::identity_create_from_addresses_transition::IdentityCreateFromAddressesTransition; +use dpp::state_transition::StateTransitionIdentityIdFromInputs; +use dpp::version::PlatformVersion; +use drive::state_transition_action::identity::identity_create_from_addresses::IdentityCreateFromAddressesTransitionAction; +use drive::state_transition_action::StateTransitionAction; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::system::bump_address_input_nonces_action::BumpAddressInputNoncesAction; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create_from_addresses) trait IdentityCreateFromAddressesStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromAddressesTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; + + +} + +impl IdentityCreateFromAddressesStateTransitionStateValidationV1 + for IdentityCreateFromAddressesTransition +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromAddressesTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + + let identity_id = self.identity_id_from_inputs()?; + let balance = + drive.fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)?; + + // Balance is here to check if the identity does already exist + if balance.is_some() { + // Since the id comes from the state transition this should never be reachable + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id.to_owned()).into(), + )); + } + + // Now we should check the state of added keys to make sure there aren't any that already exist + let mut key_state_validation_result = + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys(), + drive, + execution_context, + transaction, + platform_version, + )?; + + key_state_validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + identity_id, + self.public_keys(), + drive, + &block_info.epoch, + block_info.time_ms, + transaction, + execution_context, + platform_version, + )? + .errors, + ); + + if key_state_validation_result.is_valid() { + // We just pass the action that was given to us + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateFromAddressesAction(action), + )) + } else { + // It's not valid, we need to give back the action that partially uses the asset lock + + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present; + + let used_credits = penalty + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .ok_or(ProtocolError::Overflow("processing fee overflow error"))?; + + let bump_action = + BumpAddressInputNoncesAction::from_identity_create_from_addresses_transition_action( + action, + used_credits, + ); + Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action.into(), + key_state_validation_result.errors, + )) + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs index c295362bbdc..bb37a0084e0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/mod.rs @@ -14,6 +14,7 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::identity_create_from_shielded_pool::state::v0::IdentityCreateFromShieldedPoolStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_create_from_shielded_pool::state::v1::IdentityCreateFromShieldedPoolStateTransitionStateValidationV1; use crate::execution::validation::state_transition::identity_create_from_shielded_pool::transform_into_action::v0::IdentityCreateFromShieldedPoolStateTransitionTransformIntoActionValidationV0; use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; @@ -83,6 +84,7 @@ pub trait StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransit &self, action: IdentityCreateFromShieldedPoolTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -95,6 +97,7 @@ impl StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0 &self, action: IdentityCreateFromShieldedPoolTransitionAction, platform: &PlatformRef, + block_info: &dpp::block::block_info::BlockInfo, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -107,9 +110,17 @@ impl StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0 .state { 0 => self.validate_state_v0(platform, action, execution_context, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + action, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity create from shielded pool transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v1/mod.rs new file mode 100644 index 00000000000..c05fdf2ad53 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/state/v1/mod.rs @@ -0,0 +1,149 @@ +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use dpp::block::block_info::BlockInfo; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; +use crate::platform_types::platform::PlatformRef; +use dpp::consensus::state::identity::IdentityAlreadyExistsError; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::derive_identity_id_from_actions; +use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::shielded::identity_create_from_shielded_pool::IdentityCreateFromShieldedPoolTransitionAction; +use drive::state_transition_action::shielded::unshield::v0::UnshieldTransitionActionV0; +use drive::state_transition_action::shielded::unshield::UnshieldTransitionAction; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_create_from_shielded_pool) trait IdentityCreateFromShieldedPoolStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromShieldedPoolTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityCreateFromShieldedPoolStateTransitionStateValidationV1 + for IdentityCreateFromShieldedPoolTransition +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + action: IdentityCreateFromShieldedPoolTransitionAction, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + + // 1. The new identity must not already exist. The id is `double_sha256(sorted nullifiers)` — + // collision-resistant and derived from single-use spend tags — so this is practically + // unreachable, but check explicitly to return a clean consensus rejection. There is no + // chargeable fallback for this case (it cannot be triggered by a relayer choosing a + // colliding id), so a failure is a plain free rejection, mirroring the identity-exists + // check in `IdentityCreateFromAddresses`'s `validate_state`. + let identity_id = derive_identity_id_from_actions(self.actions()); + if drive + .fetch_identity_balance(identity_id.to_buffer(), transaction, platform_version)? + .is_some() + { + // Since the id comes entirely from the spend nullifiers this should never be reachable. + return Ok(ConsensusValidationResult::new_with_error( + IdentityAlreadyExistsError::new(identity_id).into(), + )); + } + + // 2. None of the new identity's public-key hashes may already be registered to another + // identity (platform enforces globally-unique key hashes for unique key types). Unlike the + // identity-exists check above, this CAN be triggered by an attacker re-using a victim's + // public-key hash, so it gets a chargeable fallback instead of a free rejection: on + // failure the spend is still final and the value is credited to + // `send_to_address_on_creation_failure` minus a penalty. This is topologically identical + // to an `Unshield` (pool -> address minus fee), so we reuse `UnshieldTransitionAction` + // wholesale (its converter, `PaidFromShieldedPool` execution event, and conservation). + let mut key_state_validation_result = + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys(), + drive, + execution_context, + transaction, + platform_version, + )?; + + key_state_validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + identity_id, + self.public_keys(), + drive, + &block_info.epoch, + block_info.time_ms, + transaction, + execution_context, + platform_version, + )? + .errors, + ); + + if key_state_validation_result.is_valid() { + // We just pass the success action that was built by `transform_into_action`. + Ok(ConsensusValidationResult::new_with_data( + StateTransitionAction::IdentityCreateFromShieldedPoolAction(action), + )) + } else { + // A key-state validation failure: finalize the spend and credit the fallback address minus a + // penalty. The penalty is the flat `unique_key_already_present` amount plus the metered + // processing fee accumulated so far (like `IdentityCreateFromAddresses`'s + // `BumpAddressInputNonces` penalty) PLUS the flat shielded compute fee + // (`compute_shielded_verification_fee`): the proposer ran the same Halo 2 verification on + // the failure path that the success path charges via `additional_fixed_fee_cost`, so the + // penalty floor must cover it too (fee parity with the success / other shielded paths). We + // then CAP it at the denomination so the Unshield converter's `amount.checked_sub(fee)` + // cannot underflow (a net-zero credit is the worst case: the whole spend is consumed by + // the penalty and flows to the fee pools). + let denomination = action.denomination(); + let compute_fee = dpp::shielded::compute_shielded_verification_fee( + action.notes().len(), + platform_version, + )?; + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .unique_key_already_present + .checked_add(execution_context.fee_cost(platform_version)?.processing_fee) + .and_then(|v| v.checked_add(compute_fee)) + .ok_or(ProtocolError::Overflow( + "identity create from shielded pool failure penalty overflow", + ))? + .min(denomination); + + let failure_action = UnshieldTransitionAction::V0(UnshieldTransitionActionV0 { + output_address: *self.send_to_address_on_creation_failure(), + amount: denomination, + notes: action.notes().to_vec(), + anchor: *action.anchor(), + fee_amount: penalty, + current_total_balance: action.current_total_balance(), + // This is the chargeable failure of an identity create: the `PaidFromShieldedPool` + // execution event reads this flag to apply its ops despite the attached validation + // errors (so the apply-despite-errors path is type-enforced, not comment-enforced). + chargeable_failure: true, + }); + + Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::UnshieldAction(failure_action), + key_state_validation_result.errors, + )) + } + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index 8938fb57d54..3c6e08fa081 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -119,6 +119,135 @@ fn build_success_action( } } +#[test] +fn should_validate_scoped_keys_through_shielded_creation_dispatch() { + use super::StateTransitionStateValidationForIdentityCreateFromShieldedPoolTransitionV0; + use dpp::consensus::codes::ErrorWithCode; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::identifier::Identifier; + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractBounds, ContractScope, + }; + + let version = PlatformVersion::latest(); + let platform = setup_platform(); + set_pool_total_balance(&platform, DENOMINATION * 10); + insert_anchor_into_state(&platform, &ANCHOR); + insert_dummy_encrypted_notes( + &platform, + version + .drive_abci + .validation_and_processing + .event_constants + .minimum_pool_notes_for_outgoing + .max(1), + ); + let contract = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let (valid_master, _) = + IdentityPublicKey::random_ecdsa_master_authentication_key(0, Some(31), version).unwrap(); + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + + for (case, id, document_type, time_ms, error_code) in [ + ("valid", contract.id(), "contactRequest", 99, None), + ("expired", contract.id(), "contactRequest", 100, Some(10535)), + ( + "unknown contract", + Identifier::from([0x71; 32]), + "contactRequest", + 99, + Some(10400), + ), + ( + "unknown document", + contract.id(), + "missing", + 99, + Some(10406), + ), + ] { + let scope = AuthenticationScope::V0(AuthenticationScopeV0 { + contracts: vec![ContractScope { + id, + document_types: Some(vec![document_type.into()]), + }], + permissions: permissions::DOCUMENT_CREATE, + expires_at: Some(100), + }); + let key = IdentityPublicKeyInCreationV0 { + id: 1, + key_type: KeyType::ECDSA_HASH160, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: Some(ContractBounds::Scoped(scope)), + data: vec![0x72; 20].into(), + read_only: false, + signature: Default::default(), + }; + let st = transition( + vec![valid_master.clone().into(), key.into()], + vec![action(30), action(31)], + ); + let mut context = + StateTransitionExecutionContext::default_for_platform_version(version).unwrap(); + let success = build_success_action(&platform, &st, &mut context, version); + let expected_notes = success.notes().to_vec(); + let block_info = BlockInfo { + time_ms, + ..Default::default() + }; + // Exercise the public version dispatcher, not a hard-coded v0/v1 implementation. + let result = st + .validate_state_for_identity_create_from_shielded_pool_transition( + success, + &platform_ref, + &block_info, + &mut context, + None, + ) + .unwrap(); + if let Some(error_code) = error_code { + assert_eq!(result.errors.len(), 1, "{case}: {:?}", result.errors); + assert_eq!(result.errors[0].code(), error_code); + let StateTransitionAction::UnshieldAction(fallback) = result.into_data().unwrap() + else { + panic!( + "{case}: invalid bounds must finalize the spend through the charged fallback" + ); + }; + assert!(fallback.chargeable_failure(), "{case}"); + assert_eq!(fallback.output_address(), &FALLBACK_ADDRESS); + assert_eq!(fallback.amount(), DENOMINATION); + assert_eq!(fallback.notes().len(), expected_notes.len()); + for (actual, expected) in fallback.notes().iter().zip(&expected_notes) { + assert_eq!(actual.nullifier, expected.nullifier); + assert_eq!(actual.cmx, expected.cmx); + assert_eq!(actual.cv_net, expected.cv_net); + assert_eq!(actual.encrypted_note, expected.encrypted_note); + } + assert_eq!(fallback.anchor(), &ANCHOR); + assert!(fallback.fee_amount() > 0 && fallback.fee_amount() < DENOMINATION); + } else { + assert!(result.is_valid(), "{:?}", result.errors); + assert_matches!( + result.into_data().unwrap(), + StateTransitionAction::IdentityCreateFromShieldedPoolAction(_) + ); + } + } +} + #[test] fn validate_state_rejects_when_identity_already_exists_at_derived_id() { let platform_version = PlatformVersion::latest(); 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 b07b24b12a8..070ebd28c9c 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 @@ -25,6 +25,7 @@ use crate::rpc::core::CoreRPCLike; use crate::execution::validation::state_transition::identity_update::basic_structure::v0::IdentityUpdateStateTransitionStructureValidationV0; use crate::execution::validation::state_transition::identity_update::state::v0::IdentityUpdateStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::identity_update::state::v1::IdentityUpdateStateTransitionStateValidationV1; use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::processor::state::StateTransitionStateValidation; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; @@ -95,8 +96,8 @@ impl StateTransitionStateValidation for IdentityUpdateTransition { _action: Option, platform: &PlatformRef, _validation_mode: ValidationMode, - _block_info: &BlockInfo, - _execution_context: &mut StateTransitionExecutionContext, + block_info: &BlockInfo, + execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { let platform_version = platform.state.current_platform_version()?; @@ -108,9 +109,16 @@ impl StateTransitionStateValidation for IdentityUpdateTransition { .state { 0 => self.validate_state_v0(platform, tx, platform_version), + 1 => self.validate_state_v1( + platform, + block_info, + execution_context, + tx, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "identity update transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -376,6 +384,559 @@ mod tests { }; } + #[test] + fn should_retain_contract_lookup_fees_only_after_activation() { + use super::*; + use crate::execution::types::execution_operation::ValidationOperation; + use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContextMethodsV0; + use dpp::data_contract::factory::DataContractFactory; + use dpp::platform_value::platform_value; + use dpp::version::DefaultForPlatformVersion; + + for protocol in [13, 14] { + let version = PlatformVersion::get(protocol).unwrap(); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol) + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, _, _, master) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + let factory = DataContractFactory::new(protocol).unwrap(); + let contract = factory + .create_with_value_config( + identity.id(), + 1, + platform_value!({ + "note": { "type": "object", "requiresIdentityDecryptionBoundedKey": 0_u64, + "properties": {"text": {"type": "string", "maxLength": 64, "position": 0}}, "additionalProperties": false } + }), + None, + None, + ) + .unwrap() + .data_contract_owned(); + platform + .drive + .apply_contract(&contract, BlockInfo::default(), true, None, None, version) + .unwrap(); + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + for missing in [false, true] { + let id = if missing { + Identifier::from([0x73; 32]) + } else { + contract.id() + }; + platform.drive.cache.data_contracts.clear(); + let expected = platform + .drive + .get_system_or_user_contract_with_fee( + id.to_buffer(), + &BlockInfo::default().epoch, + None, + version, + ) + .unwrap(); + let expected_fee = expected.fee().unwrap().clone(); + assert!(expected_fee.processing_fee > 0); + platform.drive.cache.data_contracts.clear(); + let update: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreationV0 { + id: 2, + purpose: Purpose::DECRYPTION, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_HASH160, + data: vec![0x74; 20].into(), + read_only: false, + signature: Default::default(), + contract_bounds: Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: "note".into(), + }), + } + .into()], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: master.id(), + signature: Default::default(), + } + .into(); + let mut context = + StateTransitionExecutionContext::default_for_platform_version(version).unwrap(); + let result = update + .validate_state( + None, + &platform_ref, + ValidationMode::Validator, + &BlockInfo::default(), + &mut context, + None, + ) + .unwrap(); + assert_eq!( + result.is_valid(), + !missing, + "protocol {protocol}: {:?}", + result.errors + ); + if missing { + assert_matches!( + result.into_data().unwrap(), + StateTransitionAction::BumpIdentityNonceAction(_) + ); + } + if protocol == 13 { + assert!( + context.operations_slice().is_empty(), + "historical v0 discards its local validation costs" + ); + } else { + assert!(context.operations_slice().iter().any(|operation| matches!(operation, + ValidationOperation::PrecalculatedOperation(fee) if fee == &expected_fee + )), "contract lookup costs must reach the caller even on paid failure"); + } + } + } + } + + #[tokio::test] + async fn should_register_scoped_authentication_key_and_preserve_proof_metadata() { + let platform_version = PlatformVersion::latest(); + + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + + let (identity, signer, _, key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::data_contract::factory::DataContractFactory; + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + use dpp::platform_value::{platform_value, Value}; + let schemas = Value::Map((0..16).map(|n| ( + Value::Text(format!("t{n:02}")), + platform_value!({"type": "object", "properties": {"text": {"type": "string", "maxLength": 64, "position": 0}}, "additionalProperties": false}), + )).collect()); + let factory = DataContractFactory::new(platform_version.protocol_version).unwrap(); + let template = factory + .create_with_value_config(identity.id(), 1, schemas, None, None) + .unwrap() + .data_contract_owned(); + let mut contracts = Vec::new(); + for id in 1..=16 { + let mut contract = template.clone(); + contract.set_id(Identifier::from([id; 32])); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + None, + None, + platform_version, + ) + .unwrap(); + contracts.push(ContractScope { + id: contract.id(), + document_types: Some((0..16).map(|n| format!("t{n:02}")).collect()), + }); + } + let first_contract = contracts[0].id; + let bounds = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts, + permissions: permissions::DOCUMENT_CREATE | permissions::DOCUMENT_TOKEN_PAYMENT, + expires_at: Some(100), + })); + let platform_state = platform.state.load(); + + let secp = Secp256k1::new(); + + let mut rng = StdRng::seed_from_u64(292); + + let new_key_pair = Keypair::new(&secp, &mut rng); + + let mut new_key = IdentityPublicKeyInCreationV0 { + id: 2, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: ECDSA_SECP256K1, + read_only: false, + data: new_key_pair.public_key().serialize().to_vec().into(), + signature: Default::default(), + contract_bounds: Some(bounds.clone()), + }; + + let update_transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreation::V0(new_key.clone())], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: key.id(), + signature: Default::default(), + } + .into(); + + let update_transition: StateTransition = update_transition.into(); + + let signable_bytes = update_transition + .signable_bytes() + .expect("expected signable bytes"); + + 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(); + + let update_transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![IdentityPublicKeyInCreation::V0(new_key)], + disable_public_keys: vec![], + user_fee_increase: 0, + signature_public_key_id: key.id(), + signature: Default::default(), + } + .into(); + + let mut update_transition: StateTransition = update_transition.into(); + + update_transition.set_signature( + signer + .sign(&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"); + + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let proof_result = platform + .platform + .drive + .prove_state_transition(&update_transition, None, platform_version) + .map_err(|e| e.to_string()) + .expect("expected to create proof"); + + if let Some(proof_error) = proof_result.first_error() { + panic!("proof_result is not valid with error {}", proof_error); + } + + let proof_data = proof_result + .into_data() + .map_err(|e| e.to_string()) + .expect("expected to get proof data"); + + let (_, verification_result) = Drive::verify_state_transition_was_executed_with_proof( + &update_transition, + &BlockInfo::default(), + &proof_data, + &|_id: &Identifier| Ok(None), + platform_version, + ) + .map(|(root_hash, outcome)| (root_hash, outcome.into_result())) + .map_err(|e| e.to_string()) + .expect("expected to verify state transition"); + + let StateTransitionProofResult::VerifiedPartialIdentity(document) = verification_result + else { + panic!( + "verification_result expected partial identity, but got: {:?}", + verification_result + ); + }; + assert_eq!( + document + .loaded_public_keys + .get(&2) + .unwrap() + .contract_bounds(), + Some(&bounds) + ); + use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyKindRequestType, KeyRequestType, + }; + let indexed = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type: KeyRequestType::ContractDocumentTypeBoundKey( + first_contract.to_buffer(), + "t00".into(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + ), + limit: None, + offset: None, + }, + None, + platform_version, + ) + .unwrap() + .unwrap(); + assert_eq!( + indexed + .loaded_public_keys + .get(&2) + .unwrap() + .contract_bounds(), + Some(&bounds) + ); + let mut revoke: StateTransition = + IdentityUpdateTransition::from(IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 2, + nonce: 2, + add_public_keys: vec![], + disable_public_keys: vec![2], + user_fee_increase: 0, + signature_public_key_id: key.id(), + signature: Default::default(), + }) + .into(); + revoke.set_signature( + signer + .sign(&key, &revoke.signable_bytes().unwrap()) + .await + .unwrap(), + ); + let block = BlockInfo { + time_ms: 50, + ..Default::default() + }; + let tx = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![revoke.serialize_to_bytes().unwrap()], + &platform_state, + &block, + &tx, + platform_version, + true, + None, + ) + .unwrap(); + assert_eq!(result.valid_count(), 1); + platform + .drive + .grove + .commit_transaction(tx) + .unwrap() + .unwrap(); + let fetched = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest::new_specific_key_query(&identity.id().to_buffer(), 2), + None, + platform_version, + ) + .unwrap() + .unwrap(); + let revoked = fetched.loaded_public_keys.get(&2).unwrap(); + assert_eq!(revoked.disabled_at(), Some(50)); + assert_eq!(revoked.contract_bounds(), Some(&bounds)); + assert!(platform + .drive + .grove + .visualize_verify_grovedb(None, true, false, &platform_version.drive.grove_version) + .unwrap() + .is_empty()); + } + + #[tokio::test] + async fn should_refresh_every_scoped_key_reference_after_revocation() { + use dpp::identity::contract_bounds::{ + authentication_scope::permissions, AuthenticationScope, AuthenticationScopeV0, + ContractScope, + }; + use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyKindRequestType, KeyRequestType, + }; + let version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let (mut identity, mut signer, _, master) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(version) + .unwrap(); + let dpns = platform + .drive + .cache + .system_data_contracts + .load_dpns(version) + .unwrap(); + let mut contracts = vec![ + ContractScope { + id: dashpay.id(), + document_types: None, + }, + ContractScope { + id: dpns.id(), + document_types: Some(vec!["preorder".into()]), + }, + ]; + contracts.sort_by_key(|entry| entry.id); + let bounds = ContractBounds::Scoped(AuthenticationScope::V0(AuthenticationScopeV0 { + contracts, + permissions: permissions::DOCUMENT_CREATE, + expires_at: None, + })); + let key = setup_add_key_to_identity( + &mut platform, + &mut identity, + &mut signer, + 4, + 2, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + Some(bounds.clone()), + ); + let mut update: StateTransition = + IdentityUpdateTransition::from(IdentityUpdateTransitionV0 { + identity_id: identity.id(), + revision: 1, + nonce: 1, + add_public_keys: vec![], + disable_public_keys: vec![key.id()], + user_fee_increase: 0, + signature_public_key_id: master.id(), + signature: Default::default(), + }) + .into(); + update.set_signature( + signer + .sign(&master, &update.signable_bytes().unwrap()) + .await + .unwrap(), + ); + let block = BlockInfo { + time_ms: 1001, + ..Default::default() + }; + let transaction = platform.drive.grove.start_transaction(); + let state = platform.state.load(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![update.serialize_to_bytes().unwrap()], + &state, + &block, + &transaction, + version, + true, + None, + ) + .unwrap(); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .unwrap(); + + // Both current-key references must resolve to the updated key, not its old hash. + for request_type in [ + KeyRequestType::ContractBoundKey( + dashpay.id().to_buffer(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + ), + KeyRequestType::ContractDocumentTypeBoundKey( + dpns.id().to_buffer(), + "preorder".into(), + Purpose::AUTHENTICATION, + KeyKindRequestType::CurrentKeyOfKindRequest, + ), + ] { + let fetched = platform + .drive + .fetch_identity_keys_as_partial_identity( + IdentityKeysRequest { + identity_id: identity.id().to_buffer(), + request_type, + limit: None, + offset: None, + }, + None, + version, + ) + .unwrap() + .unwrap(); + let refreshed = fetched + .loaded_public_keys + .get(&key.id()) + .expect("scoped key reference"); + assert_eq!(refreshed.disabled_at(), Some(block.time_ms)); + assert_eq!(refreshed.contract_bounds(), Some(&bounds)); + } + assert!( + platform + .drive + .grove + .visualize_verify_grovedb(None, true, false, &version.drive.grove_version,) + .unwrap() + .is_empty(), + "revocation must leave no stale GroveDB references" + ); + } + #[tokio::test] async fn test_identity_update_that_disables_an_encryption_key() { let platform_config = PlatformConfig { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs index 9a1925de7fc..420eb16447d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/mod.rs @@ -1 +1,3 @@ pub(crate) mod v0; + +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs index 92f7c7216d3..fbd5029d131 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v0/mod.rs @@ -102,6 +102,7 @@ impl IdentityUpdateStateTransitionStateValidationV0 for IdentityUpdateTransition self.public_keys_to_add(), drive, platform.state.last_committed_block_epoch_ref(), + 0, tx, &mut state_transition_execution_context, platform_version, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs new file mode 100644 index 00000000000..ed05f65c20f --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_update/state/v1/mod.rs @@ -0,0 +1,163 @@ +use super::v0::IdentityUpdateStateTransitionStateValidationV0; +use crate::error::Error; +use dpp::block::block_info::BlockInfo; + +use crate::platform_types::platform::PlatformRef; +use crate::rpc::core::CoreRPCLike; + +use dpp::prelude::ConsensusValidationResult; + +use dpp::state_transition::identity_update_transition::accessors::IdentityUpdateTransitionAccessorsV0; +use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; +use dpp::version::PlatformVersion; +use drive::state_transition_action::StateTransitionAction; + +use drive::grovedb::TransactionArg; +use drive::state_transition_action::system::bump_identity_nonce_action::BumpIdentityNonceAction; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::common::validate_identity_public_key_contract_bounds::validate_identity_public_keys_contract_bounds; +use crate::execution::validation::state_transition::common::validate_identity_public_key_ids_dont_exist_in_state::validate_identity_public_key_ids_dont_exist_in_state; +use crate::execution::validation::state_transition::common::validate_identity_public_key_ids_exist_in_state::validate_identity_public_key_ids_exist_in_state; +use crate::execution::validation::state_transition::common::validate_not_disabling_last_master_key::validate_master_key_uniqueness; +use crate::execution::validation::state_transition::common::validate_unique_identity_public_key_hashes_in_state::validate_unique_identity_public_key_hashes_not_in_state; + +pub(in crate::execution::validation::state_transition::state_transitions::identity_update) trait IdentityUpdateStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + state_transition_execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl IdentityUpdateStateTransitionStateValidationV1 for IdentityUpdateTransition { + fn validate_state_v1( + &self, + platform: &PlatformRef, + block_info: &BlockInfo, + state_transition_execution_context: &mut StateTransitionExecutionContext, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive = platform.drive; + let mut validation_result = ConsensusValidationResult::::default(); + + // Now we should check the state of added keys to make sure there aren't any that already exist + validation_result.add_errors( + validate_unique_identity_public_key_hashes_not_in_state( + self.public_keys_to_add(), + drive, + state_transition_execution_context, + tx, + platform_version, + )? + .errors, + ); + + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + + validation_result.add_errors( + validate_identity_public_key_ids_dont_exist_in_state( + self.identity_id(), + self.public_keys_to_add(), + drive, + tx, + state_transition_execution_context, + platform_version, + )? + .errors, + ); + + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + + // Now we should check to make sure any keys that are added are valid for the contract + // bounds they refer to + validation_result.add_errors( + validate_identity_public_keys_contract_bounds( + self.identity_id(), + self.public_keys_to_add(), + drive, + &block_info.epoch, + block_info.time_ms, + tx, + state_transition_execution_context, + platform_version, + )? + .errors, + ); + + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + + if !self.public_key_ids_to_disable().is_empty() { + let validation_result_and_keys_to_disable = + validate_identity_public_key_ids_exist_in_state( + self.identity_id(), + self.public_key_ids_to_disable(), + drive, + state_transition_execution_context, + tx, + platform_version, + )?; + // We need to validate that all keys removed existed + if !validation_result_and_keys_to_disable.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result_and_keys_to_disable.errors, + )); + } + + let keys_to_disable = validation_result_and_keys_to_disable.into_data()?; + + let validation_result = validate_master_key_uniqueness( + self.public_keys_to_add(), + keys_to_disable.as_slice(), + platform_version, + )?; + if !validation_result.is_valid() { + let bump_action = StateTransitionAction::BumpIdentityNonceAction( + BumpIdentityNonceAction::from_borrowed_identity_update_transition(self), + ); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + bump_action, + validation_result.errors, + )); + } + } + self.transform_into_action_v0() + } +} diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/mod.rs index 721ba794bcb..c79321769f0 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/mod.rs @@ -10,6 +10,7 @@ use platform_version::version::PlatformVersion; use std::collections::HashMap; mod v0; +mod v1; impl Drive { /// Adds potential contract information for a contract-bounded key. @@ -62,9 +63,18 @@ impl Drive { drive_operations, platform_version, ), + 1 => self.add_potential_contract_info_for_contract_bounded_key_v1( + identity_id, + identity_key, + epoch, + estimated_costs_only_with_layer_info, + transaction, + drive_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_potential_contract_info_for_contract_bounded_key".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs index a8b2a949f83..399403079b4 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v0/mod.rs @@ -59,7 +59,7 @@ impl Drive { self.add_contract_info_operations_v0( identity_id, epoch, - vec![contract_apply_info], + contract_apply_info, estimated_costs_only_with_layer_info, transaction, drive_operations, 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 new file mode 100644 index 00000000000..d79976be56b --- /dev/null +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/add_potential_contract_info_for_contract_bounded_key/v1/mod.rs @@ -0,0 +1,541 @@ +use crate::drive::identity::contract_info::keys::IdentityDataContractKeyApplyInfo; +use crate::drive::identity::contract_info::ContractInfoStructure::ContractInfoKeysKey; +use crate::drive::identity::IdentityRootStructure::IdentityContractInfo; +use crate::drive::identity::{ + identity_contract_info_group_keys_path_vec, identity_contract_info_group_path_key_purpose_vec, + identity_contract_info_group_path_vec, identity_contract_info_root_path_vec, + identity_key_location_within_identity_vec, identity_path_vec, +}; +use crate::drive::Drive; +use crate::error::contract::DataContractError; +use crate::error::identity::IdentityError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::QueryTarget::QueryTargetValue; +use crate::util::grove_operations::{BatchInsertApplyType, BatchInsertTreeApplyType}; +use crate::util::object_size_info::{PathKeyElementInfo, PathKeyInfo}; +use dpp::block::epoch::Epoch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v0::DataContractConfigGettersV0; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::storage_requirements::keys_for_document_type::StorageKeyRequirements; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{IdentityPublicKey, Purpose}; +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::reference_path::ReferencePathType::{SiblingReference, UpstreamRootHeightReference}; +use grovedb::{Element, EstimatedLayerInformation, TransactionArg, TreeType}; +use grovedb_costs::OperationCost; +use integer_encoding::VarInt; +use std::collections::HashMap; + +impl Drive { + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(in crate::drive::identity::contract_info) fn add_potential_contract_info_for_contract_bounded_key_v1( + &self, + identity_id: [u8; 32], + identity_key: &IdentityPublicKey, + epoch: &Epoch, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + if let Some(contract_bounds) = &identity_key.contract_bounds() { + // We need to get the contract + let contract_apply_info = IdentityDataContractKeyApplyInfo::new_from_single_key( + identity_key.id(), + identity_key.purpose(), + contract_bounds, + self, + epoch, + transaction, + drive_operations, + platform_version, + )?; + self.add_contract_info_operations_v1( + identity_id, + epoch, + contract_apply_info, + estimated_costs_only_with_layer_info, + transaction, + drive_operations, + platform_version, + )?; + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + /// Adds the contract info operations + fn add_contract_info_operations_v1( + &self, + identity_id: [u8; 32], + epoch: &Epoch, + contract_infos: Vec, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let identity_path = identity_path_vec(identity_id.as_slice()); + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_contract_info( + &identity_id, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + let apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertTreeApplyType::StatefulBatchInsertTree + } else { + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type: TreeType::NormalTree, + tree_type: TreeType::NormalTree, + flags_len: 0, + } + }; + + // we insert the contract root tree if it doesn't exist already + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey((identity_path, vec![IdentityContractInfo as u8])), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + for contract_info in contract_infos.into_iter() { + let root_id = contract_info.root_id(); + + let contract = if estimated_costs_only_with_layer_info.is_none() { + // we should start by fetching the contract + let (fee, contract) = self.get_contract_with_fetch_info_and_fee( + root_id, + Some(epoch), + true, + transaction, + platform_version, + )?; + + let fee = fee.ok_or(Error::Identity( + IdentityError::IdentityKeyDataContractNotFound, + ))?; + let contract = contract.ok_or(Error::Identity( + IdentityError::IdentityKeyDataContractNotFound, + ))?; + drive_operations.push(LowLevelDriveOperation::PreCalculatedFeeResult(fee)); + Some(contract) + } else { + drive_operations.push(LowLevelDriveOperation::CalculatedCostOperation( + OperationCost { + seek_count: 1, + storage_cost: Default::default(), + storage_loaded_bytes: 100, + hash_node_calls: 0, + sinsemilla_hash_calls: 0, + }, + )); + None + }; + + let (document_keys, contract_or_family_keys) = contract_info.keys(); + + if !contract_or_family_keys.is_empty() { + // we only need to do this once + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group( + &identity_id, + &root_id, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + + Self::add_estimation_costs_for_contract_info_group_keys( + &identity_id, + &root_id, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_root_path_vec(&identity_id), + root_id.to_vec(), + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + // We need to insert the keys parent tree + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_group_path_vec(&identity_id, &root_id), + vec![ContractInfoKeysKey as u8], + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + } + + for (key_id, purpose) in contract_or_family_keys { + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group_key_purpose( + &identity_id, + &root_id, + purpose, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + // We need to insert the key type + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_group_keys_path_vec(&identity_id, &root_id), + vec![purpose as u8], + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + // we need to add a reference to the key + let key_id_bytes = key_id.encode_var_vec(); + let key_reference = + identity_key_location_within_identity_vec(key_id_bytes.as_slice()); + + let reference_type_path = UpstreamRootHeightReference(2, key_reference); + + let ref_apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertApplyType::StatefulBatchInsert + } else { + BatchInsertApplyType::StatelessBatchInsert { + in_tree_type: TreeType::NormalTree, + target: QueryTargetValue(reference_type_path.serialized_size() as u32), + } + }; + + // at this point we want to know if the contract is single key or multiple key + let storage_key_requirements = contract + .as_ref() + .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } + Purpose::ENCRYPTION => { + let encryption_storage_key_requirements = contract + .contract + .config() + .requires_identity_encryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds for encryption", + ), + ))?; + Ok(encryption_storage_key_requirements) + } + Purpose::DECRYPTION => { + let decryption_storage_key_requirements = contract + .contract + .config() + .requires_identity_decryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds for decryption", + ), + ))?; + Ok(decryption_storage_key_requirements) + } + _ => Err(Error::Identity(IdentityError::IdentityKeyBoundsError( + "purpose not available for key bounds", + ))), + }) + .transpose()? + .unwrap_or(StorageKeyRequirements::MultipleReferenceToLatest); + + // if we are multiple we insert the key under the key bytes, otherwise it is under 0 + + if storage_key_requirements == StorageKeyRequirements::Unique { + self.batch_insert_if_not_exists( + PathKeyElementInfo::<0>::PathKeyElement(( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ), + vec![], + Element::Reference(reference_type_path, Some(1), None), + )), + ref_apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + } else { + self.batch_insert_if_not_exists( + PathKeyElementInfo::<0>::PathKeyRefElement(( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &root_id, + purpose, + ), + key_id_bytes.as_slice(), + Element::Reference(reference_type_path, Some(1), None), + )), + ref_apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + }; + + if storage_key_requirements == StorageKeyRequirements::MultipleReferenceToLatest { + // we also insert a sibling reference so we can query the current key + + let sibling_ref_type_path = SiblingReference(key_id_bytes); + + self.batch_insert( + PathKeyElementInfo::<0>::PathKeyElement(( + if purpose == Purpose::AUTHENTICATION { + // Scoped authentication'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) + }, + vec![], + Element::Reference(sibling_ref_type_path, Some(2), None), + )), + drive_operations, + &platform_version.drive, + )?; + } + } + + for (document_type_name, document_key_ids) in document_keys { + // The path is the concatenation of the contract_id and the document type name + let mut contract_id_bytes_with_document_type_name = root_id.to_vec(); + contract_id_bytes_with_document_type_name.extend(document_type_name.as_bytes()); + + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group( + &identity_id, + &contract_id_bytes_with_document_type_name, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + + Self::add_estimation_costs_for_contract_info_group_keys( + &identity_id, + &contract_id_bytes_with_document_type_name, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_root_path_vec(&identity_id), + contract_id_bytes_with_document_type_name.to_vec(), + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_group_path_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + ), + vec![ContractInfoKeysKey as u8], + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + for (key_id, purpose) in document_key_ids { + if let Some(estimated_costs_only_with_layer_info) = + estimated_costs_only_with_layer_info + { + Self::add_estimation_costs_for_contract_info_group_key_purpose( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + // We need to insert the key type + self.batch_insert_empty_tree_if_not_exists_check_existing_operations( + PathKeyInfo::<0>::PathKey(( + identity_contract_info_group_keys_path_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + ), + vec![purpose as u8], + )), + false, + None, + apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + + // we need to add a reference to the key + let key_id_bytes = key_id.encode_var_vec(); + let key_reference = + identity_key_location_within_identity_vec(key_id_bytes.as_slice()); + + let reference = UpstreamRootHeightReference(2, key_reference); + + let ref_apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertApplyType::StatefulBatchInsert + } else { + BatchInsertApplyType::StatelessBatchInsert { + in_tree_type: TreeType::NormalTree, + target: QueryTargetValue(reference.serialized_size() as u32), + } + }; + + // at this point we want to know if the contract is single key or multiple key + let storage_key_requirements = contract + .as_ref() + .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } + Purpose::ENCRYPTION => { + let document_type = contract + .contract + .document_type_for_name(document_type_name.as_str())?; + let encryption_storage_key_requirements = document_type + .requires_identity_encryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds in document type", + ), + ))?; + Ok(encryption_storage_key_requirements) + } + Purpose::DECRYPTION => { + let document_type = contract + .contract + .document_type_for_name(document_type_name.as_str())?; + let decryption_storage_key_requirements = document_type + .requires_identity_decryption_bounded_key() + .ok_or(Error::DataContract( + DataContractError::KeyBoundsExpectedButNotPresent( + "expected encryption key bounds in document type", + ), + ))?; + Ok(decryption_storage_key_requirements) + } + _ => Err(Error::Identity(IdentityError::IdentityKeyBoundsError( + "purpose not available for key bounds", + ))), + }) + .transpose()? + .unwrap_or(StorageKeyRequirements::MultipleReferenceToLatest); + + if storage_key_requirements == StorageKeyRequirements::Unique { + self.batch_insert( + PathKeyElementInfo::<0>::PathKeyElement(( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + ), + vec![], + Element::Reference(reference, Some(1), None), + )), + drive_operations, + &platform_version.drive, + )?; + } else { + self.batch_insert_if_not_exists( + PathKeyElementInfo::<0>::PathKeyElement(( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + ), + key_id_bytes.clone(), + Element::Reference(reference, Some(1), None), + )), + ref_apply_type, + transaction, + drive_operations, + &platform_version.drive, + )?; + }; + + if storage_key_requirements == StorageKeyRequirements::MultipleReferenceToLatest + { + // we also insert a sibling reference so we can query the current key + + let sibling_ref_type_path = SiblingReference(key_id_bytes); + + self.batch_insert( + PathKeyElementInfo::<0>::PathKeyElement(( + identity_contract_info_group_path_key_purpose_vec( + &identity_id, + &contract_id_bytes_with_document_type_name, + purpose, + ), + vec![], + Element::Reference(sibling_ref_type_path, Some(2), None), + )), + drive_operations, + &platform_version.drive, + )?; + } + } + } + } + + Ok(()) + } +} 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 27df5d4a0da..bcab1161423 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 @@ -68,8 +68,41 @@ impl IdentityDataContractKeyApplyInfo { transaction: TransactionArg, drive_operations: &mut Vec, platform_version: &PlatformVersion, - ) -> Result { - let contract_id = contract_bounds.identifier().to_buffer(); + ) -> Result, Error> { + if let ContractBounds::Scoped(scope) = contract_bounds { + return Ok(scope + .contracts() + .iter() + .map(|entry| { + let contract_id = entry.id; + let document_type_keys = entry + .document_types + .as_ref() + .map(|names| { + names + .iter() + .map(|name| (name.clone(), vec![(key_id, purpose)])) + .collect() + }) + .unwrap_or_default(); + ContractBased { + contract_id, + document_type_keys, + contract_keys: if entry.document_types.is_none() { + vec![(key_id, purpose)] + } else { + vec![] + }, + } + }) + .collect()); + } + let contract_id = contract_bounds + .identifier() + .ok_or(Error::Identity(IdentityError::IdentityKeyBoundsError( + "expected single contract bounds", + )))? + .to_buffer(); // we are getting with fetch info to add the cost to the drive operations let maybe_contract_fetch_info = drive.get_contract_with_fetch_info_and_add_to_operations( contract_id, @@ -86,28 +119,26 @@ impl IdentityDataContractKeyApplyInfo { }; let contract = &contract_fetch_info.contract; match contract_bounds { - ContractBounds::SingleContract { .. } => Ok(ContractBased { + ContractBounds::SingleContract { .. } => Ok(vec![ContractBased { contract_id: contract.id(), document_type_keys: Default::default(), contract_keys: vec![(key_id, purpose)], - }), + }]), ContractBounds::SingleContractDocumentType { document_type_name: document_type, .. } => { let document_type = contract.document_type_for_name(document_type)?; - Ok(ContractBased { + Ok(vec![ContractBased { contract_id: contract.id(), document_type_keys: BTreeMap::from([( document_type.name().clone(), vec![(key_id, purpose)], )]), contract_keys: vec![], - }) - } // ContractBounds::MultipleContractsOfSameOwner { .. } => Ok(ContractFamilyBased { - // contracts_owner_id: contract.owner_id(), - // family_keys: vec![key_id], - // }), + }]) + } + ContractBounds::Scoped(_) => unreachable!("handled above"), } } } diff --git a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs index eddd0c9f029..c23fcbb62a0 100644 --- a/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/contract_info/keys/refresh_potential_contract_info_key_references/v0/mod.rs @@ -53,7 +53,7 @@ impl Drive { self.refresh_contract_info_operations_v0( identity_id, epoch, - vec![contract_apply_info], + contract_apply_info, estimated_costs_only_with_layer_info, transaction, drive_operations, @@ -145,6 +145,9 @@ impl Drive { let storage_key_requirements = contract .as_ref() .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } Purpose::ENCRYPTION => { let encryption_storage_key_requirements = contract .contract @@ -203,7 +206,17 @@ impl Drive { let sibling_ref_type_path = SiblingReference(key_id_bytes); self.batch_refresh_reference( - identity_contract_info_group_keys_path_vec(&identity_id, &root_id), + if purpose == Purpose::AUTHENTICATION { + // Scoped authentication'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) + }, vec![], Element::Reference(sibling_ref_type_path, Some(2), None), true, @@ -260,6 +273,9 @@ impl Drive { let storage_key_requirements = contract .as_ref() .map(|contract| match purpose { + Purpose::AUTHENTICATION => { + Ok(StorageKeyRequirements::MultipleReferenceToLatest) + } Purpose::ENCRYPTION => { let document_type = contract .contract diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs index e2cc9994d40..42ebdb98760 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_method_versions/v3.rs @@ -2,11 +2,12 @@ use crate::version::dpp_versions::dpp_method_versions::DPPMethodVersions; /// DPP method versions 3. Introduced in protocol v14: `daily_withdrawal_limit` 1 → 2 replaces the /// flat daily withdrawal limit with a percentage of the total credits Platform held a day ago -/// (`SystemLimits::daily_withdrawal_limit_percent`). Everything else matches V2. +/// (`SystemLimits::daily_withdrawal_limit_percent`). `shielded_extra_sighash_data` 0 → 1 +/// binds contract-scoped authentication keys in shielded identity creation. Everything else matches V2. pub const DPP_METHOD_VERSIONS_V3: DPPMethodVersions = DPPMethodVersions { epoch_core_reward_credits_for_distribution: 0, daily_withdrawal_limit: 2, deduct_fee_from_outputs_or_remaining_balance_of_inputs: 0, compute_minimum_shielded_fee: 0, - shielded_extra_sighash_data: 0, + shielded_extra_sighash_data: 1, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs index ad2a16e431e..fe703b81fd6 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/mod.rs @@ -1,6 +1,7 @@ use versioned_feature_core::FeatureVersion; pub mod v1; +pub mod v2; #[derive(Clone, Debug, Default)] pub struct DPPStateTransitionMethodVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs new file mode 100644 index 00000000000..2f53cf9bfd6 --- /dev/null +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs @@ -0,0 +1,15 @@ +use crate::version::dpp_versions::dpp_state_transition_method_versions::{ + DPPStateTransitionMethodVersions, PublicKeyInCreationMethodVersions, +}; + +pub const STATE_TRANSITION_METHOD_VERSIONS_V2: DPPStateTransitionMethodVersions = + DPPStateTransitionMethodVersions { + public_key_in_creation_methods: PublicKeyInCreationMethodVersions { + from_public_key_signed_with_private_key: 0, + from_public_key_signed_external: 0, + hash: 0, + duplicated_key_ids_witness: 0, + duplicated_keys_witness: 0, + validate_identity_public_keys_structure: 1, + }, + }; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 96aef2ddb79..728ecbf8b43 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -22,10 +22,10 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = fetch_asset_lock_transaction_output_sync: 0, verify_asset_lock_is_not_spent_and_has_enough_balance: 0, }, - validate_identity_public_key_contract_bounds: 1, + validate_identity_public_key_contract_bounds: 2, validate_identity_public_key_ids_dont_exist_in_state: 0, validate_identity_public_key_ids_exist_in_state: 0, - validate_state_transition_identity_signed: 0, + validate_state_transition_identity_signed: 1, validate_unique_identity_public_key_hashes_in_state: 1, validate_master_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, @@ -37,7 +37,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: Some(0), nonce: None, - state: 0, + state: 1, transform_into_action: 0, }, identity_update_state_transition: DriveAbciStateTransitionValidationVersion { @@ -45,7 +45,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: Some(0), nonce: Some(0), - state: 0, + state: 1, transform_into_action: 0, }, identity_top_up_state_transition: DriveAbciStateTransitionValidationVersion { @@ -113,7 +113,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = data_contract_reference_validation: 0, batch_state_transition: DriveAbciDocumentsStateTransitionValidationVersions { basic_structure: 0, - advanced_structure: 0, + advanced_structure: 1, state: 0, revision: 0, // PROTOCOL_VERSION_12 (v3.1 hard fork): batch state transition @@ -230,7 +230,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: Some(0), nonce: Some(0), - state: 0, + state: 1, transform_into_action: 0, }, identity_top_up_from_addresses_state_transition: @@ -312,7 +312,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: None, identity_signatures: None, nonce: None, - state: 0, + state: 1, transform_into_action: 0, }, }, 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 482c0bc7252..95b05a0102c 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 @@ -14,8 +14,11 @@ use crate::version::drive_versions::drive_identity_method_versions::{ }; /// V2 is protocol version 14's identity-method table. It differs from V1 in -/// its withdrawal methods: +/// its contract-bound key indexing and withdrawal methods: /// +/// * `contract_info.add_potential_contract_info_for_contract_bounded_key` 0 -> 1: +/// supports scoped authentication-key references. V0 preserves the historical +/// rejection of authentication keys with legacy contract bounds before v14. /// * `withdrawals.document.find_withdrawal_documents_by_status_and_transaction_indices` /// 0 -> 1, selecting the v1 withdrawal-by-transaction-index query builder /// that carries the transaction-index `In` clause in @@ -141,7 +144,7 @@ pub const DRIVE_IDENTITY_METHOD_VERSIONS_V2: DriveIdentityMethodVersions = add_new_identity: 0, }, contract_info: DriveIdentityContractInfoMethodVersions { - add_potential_contract_info_for_contract_bounded_key: 0, + add_potential_contract_info_for_contract_bounded_key: 1, refresh_potential_contract_info_key_references: 0, merge_identity_contract_nonce: 0, }, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 7589c485738..bd4885043dd 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -7,7 +7,7 @@ use crate::version::dpp_versions::dpp_factory_versions::v1::DPP_FACTORY_VERSIONS use crate::version::dpp_versions::dpp_identity_versions::v1::IDENTITY_VERSIONS_V1; use crate::version::dpp_versions::dpp_method_versions::v3::DPP_METHOD_VERSIONS_V3; use crate::version::dpp_versions::dpp_state_transition_conversion_versions::v2::STATE_TRANSITION_CONVERSION_VERSIONS_V2; -use crate::version::dpp_versions::dpp_state_transition_method_versions::v1::STATE_TRANSITION_METHOD_VERSIONS_V1; +use crate::version::dpp_versions::dpp_state_transition_method_versions::v2::STATE_TRANSITION_METHOD_VERSIONS_V2; use crate::version::dpp_versions::dpp_state_transition_serialization_versions::v3::STATE_TRANSITION_SERIALIZATION_VERSIONS_V3; use crate::version::dpp_versions::dpp_state_transition_versions::v3::STATE_TRANSITION_VERSIONS_V3; use crate::version::dpp_versions::dpp_token_versions::v2::TOKEN_VERSIONS_V2; @@ -194,6 +194,10 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// where-clause operator enum gains `IN_TIME_RANGE = 11`, which pre-v14 /// servers reject as an unknown operator rather than misread (the v0 wire /// has no time-range operator at all). +/// Contract-scoped authentication keys activate through key-structure v1, bounds v2, +/// signature authorization v1 and batch advanced-structure v1. Identity creation +/// now validates key bounds, and identity-update state v1 retains validation fees. +/// Shielded identity creation binds scope metadata using extra-sighash-data v1. pub const PLATFORM_V14: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_14, drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot @@ -210,7 +214,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { validation: DPP_VALIDATION_VERSIONS_V5, state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, // changed: the indexOnly delete-by-values kind (documentIndexOnlyDelete) joins the wire state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, - state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V1, + state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, state_transitions: STATE_TRANSITION_VERSIONS_V3, contract_versions: CONTRACT_VERSIONS_V6, // changed: v3 document meta-schema hosts the ranked, refersTo, requiredSince and timeRange keywords document_versions: DOCUMENT_VERSIONS_V4, // changed: document serialization format 3 — the contract version stamp that enables `requiredSince` properties diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 2d0c526e034..fbc89290aa7 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -652,11 +652,41 @@ impl IdentityKeyEntryFFI { /// caller owns the heap-allocated `public_key_data_ptr` byte /// buffer and (when present) the /// `contract_bounds_document_type` C-string; release both via - /// [`free_identity_key_entry_ffi`]. - pub fn from_entry(entry: &IdentityKeyEntry) -> Self { + /// [`free_identity_key_entry_ffi`]. Scoped keys are rejected before any + /// allocation because this ABI cannot preserve their authorization bounds. + pub fn from_entry(entry: &IdentityKeyEntry) -> Result { use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; + // Project the DPP `ContractBounds` enum into the kind / + // id / doc-type-cstring trio so the Swift side can switch + // on a single discriminant. Strings containing interior + // NULs (impossible in practice — DPP rejects them) keep + // the discriminant + payload self-consistent by falling + // back to `SingleContract { id }` (kind=1 + null doc-type + // pointer); emitting kind=2 with a null doc-type pointer + // would silently strip the bound on the Swift side, so + // demoting to `SingleContract` is the closest faithful + // representation — the document-type qualifier is the + // only thing lost, the contract id is preserved. + let (contract_bounds_kind, contract_bounds_id, contract_bounds_document_type) = match entry + .public_key + .contract_bounds() + { + Some(ContractBounds::SingleContract { id }) => (1u8, id.to_buffer(), ptr::null()), + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => match CString::new(document_type_name.as_str()) { + Ok(c) => (2u8, id.to_buffer(), c.into_raw() as *const c_char), + Err(_) => (1u8, id.to_buffer(), ptr::null()), + }, + Some(ContractBounds::Scoped(_)) => { + return Err("scoped authentication keys require a newer native persistence ABI"); + } + None => (0u8, [0u8; 32], ptr::null()), + }; + let pk_bytes = entry.public_key.data().as_slice().to_vec(); let pk_len = pk_bytes.len(); let pk_boxed = pk_bytes.into_boxed_slice(); @@ -678,31 +708,7 @@ impl IdentityKeyEntryFFI { None => (false, 0, 0), }; - // Project the DPP `ContractBounds` enum into the kind / - // id / doc-type-cstring trio so the Swift side can switch - // on a single discriminant. Strings containing interior - // NULs (impossible in practice — DPP rejects them) keep - // the discriminant + payload self-consistent by falling - // back to `SingleContract { id }` (kind=1 + null doc-type - // pointer); emitting kind=2 with a null doc-type pointer - // would silently strip the bound on the Swift side, so - // demoting to `SingleContract` is the closest faithful - // representation — the document-type qualifier is the - // only thing lost, the contract id is preserved. - let (contract_bounds_kind, contract_bounds_id, contract_bounds_document_type) = - match entry.public_key.contract_bounds() { - Some(ContractBounds::SingleContract { id }) => (1u8, id.to_buffer(), ptr::null()), - Some(ContractBounds::SingleContractDocumentType { - id, - document_type_name, - }) => match CString::new(document_type_name.as_str()) { - Ok(c) => (2u8, id.to_buffer(), c.into_raw() as *const c_char), - Err(_) => (1u8, id.to_buffer(), ptr::null()), - }, - None => (0u8, [0u8; 32], ptr::null()), - }; - - Self { + Ok(Self { identity_id: entry.identity_id.to_buffer(), key_id: entry.key_id, purpose: entry.public_key.purpose() as u8, @@ -722,7 +728,7 @@ impl IdentityKeyEntryFFI { contract_bounds_kind, contract_bounds_id, contract_bounds_document_type, - } + }) } } @@ -1187,7 +1193,7 @@ mod tests { key_index: 5, }), }; - let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry).unwrap(); assert_eq!(ffi.identity_id, [2u8; 32]); assert_eq!(ffi.key_id, 5); assert_eq!(ffi.purpose, Purpose::AUTHENTICATION as u8); @@ -1229,7 +1235,7 @@ mod tests { wallet_id: None, derivation_indices: None, }; - let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry).unwrap(); assert!(!ffi.wallet_id_is_some); assert!(!ffi.derivation_indices_is_some); assert!(ffi.read_only); @@ -1240,6 +1246,70 @@ mod tests { unsafe { free_identity_key_entry_ffi(&mut ffi) }; } + #[test] + fn scoped_keys_are_rejected_before_native_persistence_callbacks() { + use crate::persistence::{FFIPersister, PersistenceCallbacks}; + use dpp::identity::contract_bounds::{ + AuthenticationScope, AuthenticationScopeV0, ContractBounds, ContractScope, + }; + use platform_wallet::changeset::PlatformWalletPersistence; + use platform_wallet::{IdentityKeysChangeSet, PlatformWalletChangeSet}; + use std::sync::atomic::{AtomicBool, Ordering}; + + unsafe extern "C" fn begin(context: *mut std::ffi::c_void, _: *const u8) -> i32 { + (*(context as *const AtomicBool)).store(true, Ordering::SeqCst); + 0 + } + + let entry = IdentityKeyEntry { + identity_id: Identifier::from([1; 32]), + key_id: 1, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 1, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: Some(ContractBounds::Scoped(AuthenticationScope::V0( + AuthenticationScopeV0 { + contracts: vec![ContractScope { + id: Identifier::from([2; 32]), + document_types: None, + }], + permissions: 1, + expires_at: None, + }, + ))), + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2; 33]), + disabled_at: None, + }), + public_key_hash: [0; 20], + wallet_id: None, + derivation_indices: None, + }; + assert!(IdentityKeyEntryFFI::from_entry(&entry).is_err()); + + let began = AtomicBool::new(false); + let persister = FFIPersister::new(PersistenceCallbacks { + context: &began as *const AtomicBool as *mut std::ffi::c_void, + on_changeset_begin_fn: Some(begin), + ..Default::default() + }); + let changeset = PlatformWalletChangeSet { + identity_keys: Some(IdentityKeysChangeSet { + upserts: [((entry.identity_id, entry.key_id), entry)].into(), + ..Default::default() + }), + ..Default::default() + }; + assert!(persister.store([3; 32], changeset).is_err()); + assert!(!began.load(Ordering::SeqCst)); + assert!(persister + .store([3; 32], PlatformWalletChangeSet::default()) + .is_ok()); + assert!(began.load(Ordering::SeqCst)); + } + #[test] fn test_identity_key_entry_ffi_contract_bounds_single_contract() { use dpp::identity::identity_public_key::contract_bounds::ContractBounds; @@ -1262,7 +1332,7 @@ mod tests { wallet_id: None, derivation_indices: None, }; - let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry).unwrap(); assert_eq!(ffi.contract_bounds_kind, 1); assert_eq!(ffi.contract_bounds_id, [0xAB; 32]); assert!(ffi.contract_bounds_document_type.is_null()); @@ -1294,7 +1364,7 @@ mod tests { wallet_id: None, derivation_indices: None, }; - let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); + let mut ffi = IdentityKeyEntryFFI::from_entry(&entry).unwrap(); assert_eq!(ffi.contract_bounds_kind, 2); assert_eq!(ffi.contract_bounds_id, [0xCD; 32]); assert!(!ffi.contract_bounds_document_type.is_null()); diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 4f3d9cae544..670d9630e6b 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -119,6 +119,10 @@ fn encode_contract_bounds( ), )), }, + Some(ContractBounds::Scoped(_)) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + "scoped authentication keys require a newer native inspection ABI", + )), None => Ok((0u8, [0u8; 32], ptr::null_mut())), } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 52148306238..4dae5e6fe4c 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1694,6 +1694,23 @@ impl PlatformWalletPersistence for FFIPersister { wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + // The legacy native ABI cannot represent Scoped. Reject the whole + // round before any callback; never persist an unrestricted projection. + if let Some(keys) = &changeset.identity_keys { + use dpp::identity::contract_bounds::ContractBounds; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + if keys.upserts.values().any(|entry| { + matches!( + entry.public_key.contract_bounds(), + Some(ContractBounds::Scoped(_)) + ) + }) { + return Err(PersistenceError::backend( + "scoped authentication keys require a newer native persistence ABI", + )); + } + } + // Serialize the ENTIRE begin→per-kind→end round against every // other round producer (see `round_lock`'s field doc and // dashpay/platform#4069). The lock is a synchronous @@ -2131,11 +2148,10 @@ impl PlatformWalletPersistence for FFIPersister { // `PersistentPublicKey` rows. if let Some(ref keys_cs) = changeset.identity_keys { if let Some(cb) = self.callbacks.on_persist_identity_keys_fn { - let mut upserts: Vec = keys_cs - .upserts - .values() - .map(IdentityKeyEntryFFI::from_entry) - .collect(); + let mut upserts = Vec::with_capacity(keys_cs.upserts.len()); + let projection = keys_cs.upserts.values().try_for_each(|entry| { + IdentityKeyEntryFFI::from_entry(entry).map(|entry| upserts.push(entry)) + }); let removed: Vec = keys_cs .removed .iter() @@ -2144,19 +2160,25 @@ impl PlatformWalletPersistence for FFIPersister { key_id: *key_id, }) .collect(); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - upserts.as_ptr(), - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), - ) + let result = if projection.is_ok() { + unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + upserts.as_ptr(), + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + } + } else { + // Preserve rollback and free every successful projection + // on any projection failure added in the future. + -1 }; for entry in upserts.iter_mut() { unsafe { free_identity_key_entry_ffi(entry) }; diff --git a/packages/wasm-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs b/packages/wasm-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs new file mode 100644 index 00000000000..75656eb1f44 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/basic/identity/invalid_authentication_scope_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::basic::identity::InvalidAuthenticationScopeError; +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=InvalidAuthenticationScopeError)] +pub struct InvalidAuthenticationScopeErrorWasm { + inner: InvalidAuthenticationScopeError, +} + +impl From<&InvalidAuthenticationScopeError> for InvalidAuthenticationScopeErrorWasm { + fn from(e: &InvalidAuthenticationScopeError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=InvalidAuthenticationScopeError)] +impl InvalidAuthenticationScopeErrorWasm { + #[wasm_bindgen(js_name=getCode)] + pub fn get_code(&self) -> u32 { + ConsensusError::from(self.inner.clone()).code() + } + + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.inner.to_string() + } +} diff --git a/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs b/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs index 95db5d69870..1e0eb0b2f82 100644 --- a/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs +++ b/packages/wasm-dpp/src/errors/consensus/basic/identity/mod.rs @@ -57,3 +57,6 @@ pub use invalid_instant_asset_lock_proof_signature_error::*; pub use missing_master_public_key_error::*; pub use missing_public_key_error::*; pub use not_implemented_credit_withdrawal_transition_pooling_error::*; + +mod invalid_authentication_scope_error; +pub use invalid_authentication_scope_error::InvalidAuthenticationScopeErrorWasm; diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 5c76989dc95..80ec2415ec7 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -1,3 +1,7 @@ +use super::basic::identity::InvalidAuthenticationScopeErrorWasm; +use super::signature::ScopedKeyExpiredErrorWasm; +use super::signature::ScopedKeyNonBatchErrorWasm; +use super::signature::ScopedKeyOutOfScopeErrorWasm; use crate::errors::consensus::basic::{ IncompatibleProtocolVersionErrorWasm, InvalidIdentifierErrorWasm, InvalidSignaturePublicKeyPurposeErrorWasm, JsonSchemaErrorWasm, @@ -656,6 +660,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { InvalidIdentityAssetLockTransactionError(e) => { InvalidIdentityAssetLockTransactionErrorWasm::from(e).into() } + dpp::consensus::basic::BasicError::InvalidAuthenticationScopeError(e) => { + InvalidAuthenticationScopeErrorWasm::from(e).into() + } IdentityAssetLockTransactionTooManyInputsError(e) => { IdentityAssetLockTransactionTooManyInputsErrorWasm::from(e).into() } @@ -1049,6 +1056,11 @@ fn from_signature_error(signature_error: &SignatureError) -> JsValue { SignatureError::InvalidSignaturePublicKeyPurposeError(err) => { InvalidSignaturePublicKeyPurposeErrorWasm::from(err).into() } + SignatureError::ScopedKeyNonBatchError(err) => ScopedKeyNonBatchErrorWasm::from(err).into(), + SignatureError::ScopedKeyExpiredError(err) => ScopedKeyExpiredErrorWasm::from(err).into(), + SignatureError::ScopedKeyOutOfScopeError(err) => { + ScopedKeyOutOfScopeErrorWasm::from(err).into() + } SignatureError::UncompressedPublicKeyNotAllowedError(err) => { UncompressedPublicKeyNotAllowedErrorWasm::from(err).into() } diff --git a/packages/wasm-dpp/src/errors/consensus/signature/mod.rs b/packages/wasm-dpp/src/errors/consensus/signature/mod.rs index 8b5b27b3f10..b1ad818a97e 100644 --- a/packages/wasm-dpp/src/errors/consensus/signature/mod.rs +++ b/packages/wasm-dpp/src/errors/consensus/signature/mod.rs @@ -9,3 +9,12 @@ pub use basic_ecdsa_error::*; pub use identity_not_found_error::*; pub use signature_should_not_be_present_error::*; pub use uncompressed_public_key_not_allowed_error::*; + +mod scoped_key_non_batch_error; +pub use scoped_key_non_batch_error::ScopedKeyNonBatchErrorWasm; + +mod scoped_key_expired_error; +pub use scoped_key_expired_error::ScopedKeyExpiredErrorWasm; + +mod scoped_key_out_of_scope_error; +pub use scoped_key_out_of_scope_error::ScopedKeyOutOfScopeErrorWasm; diff --git a/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs new file mode 100644 index 00000000000..2ad0c9aa498 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_expired_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ScopedKeyExpiredError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ScopedKeyExpiredError)] +pub struct ScopedKeyExpiredErrorWasm { + inner: ScopedKeyExpiredError, +} + +impl From<&ScopedKeyExpiredError> for ScopedKeyExpiredErrorWasm { + fn from(e: &ScopedKeyExpiredError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ScopedKeyExpiredError)] +impl ScopedKeyExpiredErrorWasm { + #[wasm_bindgen(js_name=getCode)] + pub fn get_code(&self) -> u32 { + ConsensusError::from(self.inner.clone()).code() + } + + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.inner.to_string() + } +} diff --git a/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs new file mode 100644 index 00000000000..d19206280a5 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_non_batch_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ScopedKeyNonBatchError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ScopedKeyNonBatchError)] +pub struct ScopedKeyNonBatchErrorWasm { + inner: ScopedKeyNonBatchError, +} + +impl From<&ScopedKeyNonBatchError> for ScopedKeyNonBatchErrorWasm { + fn from(e: &ScopedKeyNonBatchError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ScopedKeyNonBatchError)] +impl ScopedKeyNonBatchErrorWasm { + #[wasm_bindgen(js_name=getCode)] + pub fn get_code(&self) -> u32 { + ConsensusError::from(self.inner.clone()).code() + } + + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.inner.to_string() + } +} diff --git a/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs new file mode 100644 index 00000000000..0ac94a61331 --- /dev/null +++ b/packages/wasm-dpp/src/errors/consensus/signature/scoped_key_out_of_scope_error.rs @@ -0,0 +1,29 @@ +use dpp::consensus::codes::ErrorWithCode; +use dpp::consensus::signature::ScopedKeyOutOfScopeError; +use dpp::consensus::ConsensusError; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name=ScopedKeyOutOfScopeError)] +pub struct ScopedKeyOutOfScopeErrorWasm { + inner: ScopedKeyOutOfScopeError, +} + +impl From<&ScopedKeyOutOfScopeError> for ScopedKeyOutOfScopeErrorWasm { + fn from(e: &ScopedKeyOutOfScopeError) -> Self { + Self { inner: e.clone() } + } +} + +#[wasm_bindgen(js_class=ScopedKeyOutOfScopeError)] +impl ScopedKeyOutOfScopeErrorWasm { + #[wasm_bindgen(js_name=getCode)] + pub fn get_code(&self) -> u32 { + ConsensusError::from(self.inner.clone()).code() + } + + #[wasm_bindgen(getter)] + pub fn message(&self) -> String { + self.inner.to_string() + } +} diff --git a/packages/wasm-dpp2/src/data_contract/contract_bounds.rs b/packages/wasm-dpp2/src/data_contract/contract_bounds.rs index f8081c78d43..ac720b34fed 100644 --- a/packages/wasm-dpp2/src/data_contract/contract_bounds.rs +++ b/packages/wasm-dpp2/src/data_contract/contract_bounds.rs @@ -1,3 +1,4 @@ +use crate::error::WasmDppError; use crate::error::WasmDppResult; use crate::identifier::{IdentifierLikeJs, IdentifierWasm}; use crate::impl_try_from_js_value; @@ -99,8 +100,8 @@ impl ContractBoundsWasm { } #[wasm_bindgen(getter = "identifier")] - pub fn id(&self) -> IdentifierWasm { - (*self.0.identifier()).into() + pub fn id(&self) -> Option { + self.0.identifier().copied().map(Into::into) } #[wasm_bindgen(getter = "documentTypeName")] @@ -126,6 +127,11 @@ impl ContractBoundsWasm { let contract_id: Identifier = contract_id.try_into()?; self.0 = match self.clone().0 { + ContractBounds::Scoped(_) => { + return Err(WasmDppError::invalid_argument( + "replace the complete scope to change scoped bounds", + )); + } ContractBounds::SingleContract { .. } => { ContractBounds::SingleContract { id: contract_id } } @@ -144,8 +150,13 @@ impl ContractBoundsWasm { pub fn set_document_type_name( &mut self, #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, - ) { + ) -> WasmDppResult<()> { self.0 = match self.clone().0 { + ContractBounds::Scoped(_) => { + return Err(WasmDppError::invalid_argument( + "replace the complete scope to change scoped bounds", + )); + } ContractBounds::SingleContract { .. } => self.clone().0, ContractBounds::SingleContractDocumentType { id, .. } => { ContractBounds::SingleContractDocumentType { @@ -153,7 +164,8 @@ impl ContractBoundsWasm { document_type_name, } } - } + }; + Ok(()) } } diff --git a/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs b/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs index c8d3e38fbdc..89c3da84eb2 100644 --- a/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs +++ b/packages/wasm-drive-verify/src/identity/verify_identity_keys_by_identity_id.rs @@ -204,6 +204,11 @@ fn serialize_identity_public_key(key: &IdentityPublicKey) -> Result { let bounds_obj = Object::new(); match bounds { + dpp::identity::contract_bounds::ContractBounds::Scoped(scope) => { + Reflect::set(&bounds_obj, &JsValue::from_str("type"), &JsValue::from_str("Scoped"))?; + let value = serde_wasm_bindgen::to_value(scope).map_err(|e| JsValue::from_str(&e.to_string()))?; + Reflect::set(&bounds_obj, &JsValue::from_str("scope"), &value)?; + } dpp::identity::identity_public_key::contract_bounds::ContractBounds::SingleContract { id } => { Reflect::set(&bounds_obj, &JsValue::from_str("type"), &JsValue::from_str("SingleContract")) .map_err(|_| JsValue::from_str("Failed to set bounds type"))?;