diff --git a/book/src/data-model/contract-moderation.md b/book/src/data-model/contract-moderation.md index 582ce7969ea..d90df67f139 100644 --- a/book/src/data-model/contract-moderation.md +++ b/book/src/data-model/contract-moderation.md @@ -296,6 +296,8 @@ The declaration lives in `packages/rs-dpp/src/data_contract/config/moderation/el **Referencing an elected contract.** A document type that must point at a contract of this kind says so in its reference: `"refersTo": { "type": "contract", "contractRequirements": { "moderation": "elected" } }`. `contractRequirements` holds what the referenced contract must declare beyond existing, each key an aspect of the contract with a closed set of values or a bound: `moderation: "elected"`, or `moderation: "electionOpen"`, which also requires the contract's own election delay to have passed since its creation, or the contract to declare none (the delay between a contract's creation and the first charter against it, so a team cannot be seated before anyone has seen the contract, set by each contract for itself). Both have a user in the charter contract: a charter proposal only needs the target to be `elected`, so teams can form during the notice, and the charter that opens the contest needs its election `electionOpen`; `minimumAgeSeconds`, a number of seconds the reference fixes, which requires the contract's recorded creation time to be at least that far before the block time of the write; `minimumSecondsSinceUpdate`, the same of the later of the contract's creation and last update times (any update restarts the clock; an elected declaration can not be added by an update, so this one is for other uses than the charter); `owner`, `"self"` requiring the referenced contract to be owned by the writer of the referring document (its `$ownerId`, a write gate like the `$ownerId` property agreement of a document reference) and `"other"` by anyone else (so a charter may forbid an owner from chartering its own team); `readonly: true`, requiring the referenced contract's config to be read-only, one that can never be updated again (which makes `minimumSecondsSinceUpdate` moot for the same target); `keepsHistory: true`, requiring its config to keep history (only `true` is declarable for either flag); and `ownerProtected`, requiring the contract's elected moderation declaration to protect the owner from the team (`true`) or to leave it unprotected (`false`), which implies elected moderation without the schema having to say so, a contract without an elected declaration meeting neither value. A contract created before contracts recorded their creation time never meets a duration, its own election delay included. Consensus checks them when the referring document is written, against the contract it has already fetched for the existence check and the write itself (its owner and block time), so they cost no further read; a contract that exists but does not meet a requirement refuses the write, paid, with `ReferencedContractRequirementNotMetError` (40135) naming the requirement, where a contract that does not exist is still 40120. A changed `contractRequirements` is an incompatible schema change on update, like the rest of a `refersTo`. The charter system contract's `targetContractId` is the first user. +**Referencing an identity key with requirements.** The same shape serves the key references the charter contract needs: `"refersTo": { "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } }`. `keyRequirements` holds what the referenced key must be beyond existing and not being disabled, each key an aspect of the key: `purpose`, the key's purpose by its wire name (`authentication`, `encryption`, `decryption`, `transfer`, `voting` or `owner`; never `system`), and `boundTo`, the name of a document type of the declaring contract, which requires the key's contract bounds to be exactly the declaring contract and that document type; a whole-contract bound or a contract group bound never meets it, even where the group holds the type, since the check reads nothing beyond the key. Registration (`create_document_types_from_document_schemas` 1, a post-pass edited in place since it is inert before protocol version 14, under full validation like the meta-schema) checks that `boundTo` names a document type the contract has, so the write-time check never needs a second contract fetch, and that a key meeting the pair can exist at all: only authentication, encryption and decryption keys carry a document type bound, and Drive registers an encryption or decryption key bound to a document type only when that type declares `requiresIdentityEncryptionBoundedKey` or `requiresIdentityDecryptionBoundedKey`, so a `boundTo` paired with `transfer`, `voting` or `owner`, or with an encryption purpose on a type without the matching keyword, is refused as a requirement no key could ever meet. Consensus checks the requirements when the referring document is written, against the key it has already fetched for the existence check, so they cost no further read; a key that exists and is enabled but does not meet one refuses the write, paid, with `ReferencedIdentityKeyRequirementNotMetError` (40136) naming the document type, the property, the requirement and what the key has, where a missing key is still 40123 and a disabled one 40124. A replace that repoints the reference at another key, through either the identity id or the key id, re-checks them. A changed `keyRequirements` is an incompatible schema change on update, like the rest of a `refersTo`. New requirements (a security level, say) are new keys of the same object, never a new reference type. The charter contract's `joinRequest.recipientId` (a decryption key bound to `submittedCharter`) is the first user. + **What comes next.** The charter system contract, applications and the election (new vote poll kinds), the seated team under the contract with its per-ability powers, charter-priced moderators amounts within the maximums, and challenges and amendments. Issue #4865 holds the design. ## Versioning Touchpoints diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index f288c3be152..23634be975c 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -215,6 +215,30 @@ "maxLength": 256, "pattern": "^[a-zA-Z0-9_]{1,64}(\\.[a-zA-Z0-9_]{1,64})*$" }, + "keyRequirements": { + "description": "identityPublicKey references only: what the referenced key must be beyond existing and not being disabled, checked when the referring document is written against the key already fetched for the existence check, so a requirement costs no further read. Each key names an aspect of the referenced key and its value the requirement: purpose requires the key's purpose to be the named one (any but system); boundTo names a document type of the declaring contract and requires the key's contract bounds to be exactly the declaring contract and that document type (a whole-contract or contract group bound never meets it). At registration boundTo must name a document type the contract has, and one a key of the required purpose can be bound to: only authentication, encryption and decryption keys carry a document type bound, and an encryption or decryption key only where the type declares requiresIdentityEncryptionBoundedKey or requiresIdentityDecryptionBoundedKey. An unmet requirement refuses the write (ReferencedIdentityKeyRequirementNotMetError, 40136)", + "type": "object", + "properties": { + "purpose": { + "enum": [ + "authentication", + "encryption", + "decryption", + "transfer", + "voting", + "owner" + ] + }, + "boundTo": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-zA-Z0-9_]{1,64}$" + } + }, + "minProperties": 1, + "additionalProperties": false + }, "propertyAgreement": { "description": "permanentDocument and deletableDocument references only: each { referring property: referenced property } pair must hold as an equality between the referring document's value and the referenced document's value, enforced by consensus at document write time. The referring side is a schema property of the declaring document type or its own $ownerId, the writer, which turns the pair into a write gate: only an identity whose id equals the referenced side may create or replace the document. The referenced side is a schema property of the referenced document type, or one of its $ownerId and $creatorId system identifiers, in which case the referring property must be an identifier; $creatorId additionally needs a referenced document type that records creator ids (transferable or tradeable types of a format-1 contract). Both sides must exist and share one value kind, validated at contract registration. $ownerId follows the referenced document through transfers while $creatorId never changes; either is checked when the referring document is written, not when the referenced document later moves", "type": "object", @@ -280,7 +304,8 @@ }, "else": { "properties": { - "keyIdProperty": false + "keyIdProperty": false, + "keyRequirements": false } } }, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/mod.rs index 2ccd4283987..388cff3f589 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/mod.rs @@ -68,7 +68,9 @@ impl DocumentType { validation_operations, platform_version, ), - // in v1 we add the ability to have contracts without documents and just tokens + // in v1 we add the ability to have contracts without documents and just tokens; + // from protocol version 14 it also checks an identity key reference's + // keyRequirements.boundTo, inert before (see v1) 1 => DocumentType::create_document_types_from_document_schemas_v1( data_contract_id, data_contract_system_version, diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs index 6529bd47f95..28c3a6762d2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/create_document_types_from_document_schemas/v1/mod.rs @@ -1,8 +1,13 @@ use crate::consensus::basic::data_contract::DocumentTypesAreMissingError; use crate::data_contract::config::DataContractConfig; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::class_methods::consensus_or_protocol_data_contract_error; -use crate::data_contract::document_type::DocumentType; +use crate::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentType, +}; +use crate::data_contract::errors::DataContractError; use crate::data_contract::{DocumentName, TokenConfiguration, TokenContractPosition}; +use crate::identity::Purpose; use crate::validation::operations::ProtocolValidationOperation; use crate::version::PlatformVersion; use crate::ProtocolError; @@ -64,6 +69,359 @@ impl DocumentType { contract_document_types.insert(name.to_string(), document_type); } + + // Protocol version 14 and later: an `identityPublicKey` reference's + // `keyRequirements.boundTo` must name a document type of this contract, and one a + // key of the required purpose can be bound to. A document type's parse sees only its + // own schema, so the check runs here, once every document type is parsed, and only + // under full validation (registration), like the meta-schema: a contract read back + // from state passed it when it was written. It holds the reference's promise that + // the write-time check never needs a second contract fetch: the bound the key must + // carry names the declaring contract and one of its own document types. + // + // Inert for every protocol version before 14, which also select this generation: + // `key_requirements` exists on a parsed reference only where the tables carry + // `apply_property_reference: Some(_)`, which no version before 14 does (their + // meta-schemas refuse `refersTo` and their parser ignores it), so the loop below + // finds no requirement to check there and the output is unchanged. + if !full_validation { + return Ok(contract_document_types); + } + + for (name, document_type) in &contract_document_types { + for (path, property) in document_type.as_ref().flattened_properties() { + let DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_requirements, .. + }, + ) = &property.property_type + else { + continue; + }; + let Some(bound_to) = &key_requirements.bound_to else { + continue; + }; + let Some(bound_document_type) = contract_document_types.get(bound_to) else { + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "{name}.{path} refersTo keyRequirements boundTo {bound_to:?} names no document type of this contract" + )), + )); + }; + // Only these purposes carry a document type bound, and the two encryption + // purposes only where the bound type declares that it takes such keys; + // any other pairing is a requirement no key could ever meet + let bound_type_takes_the_key = match key_requirements.purpose { + None | Some(Purpose::AUTHENTICATION) => true, + Some(Purpose::ENCRYPTION) => bound_document_type + .as_ref() + .requires_identity_encryption_bounded_key() + .is_some(), + Some(Purpose::DECRYPTION) => bound_document_type + .as_ref() + .requires_identity_decryption_bounded_key() + .is_some(), + Some(_) => false, + }; + if !bound_type_takes_the_key { + let purpose = key_requirements + .purpose + .map(|purpose| purpose.wire_name()) + .unwrap_or_default(); + return Err(consensus_or_protocol_data_contract_error( + DataContractError::InvalidContractStructure(format!( + "{name}.{path} refersTo keyRequirements requires a {purpose} key bound to document type {bound_to:?}, which no key can be: only authentication, encryption and decryption keys carry a document type bound, and an encryption or decryption key only where the type declares requiresIdentityEncryptionBoundedKey or requiresIdentityDecryptionBoundedKey" + )), + )); + } + } + } + Ok(contract_document_types) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; + use crate::data_contract::document_type::IdentityKeyReferenceRequirements; + use crate::data_contract::DataContract; + use crate::identity::Purpose; + use crate::serialization::{ + PlatformDeserializableWithPotentialValidationFromVersionedStructureUntrusted, + PlatformSerializableWithPlatformVersion, + }; + use platform_value::platform_value; + use platform_value::string_encoding::Encoding; + use std::ops::Deref; + + /// A contract with a `joinRequest` type whose `recipientId` references an identity key + /// with `refers_to`, and a `submittedCharter` type for a bound to name. Both types declare + /// that they take bound encryption and decryption keys. + fn contract_value(refers_to: Value) -> Value { + contract_value_taking_bound_keys(refers_to, true) + } + + /// [`contract_value`], with or without the two bound key keywords on both types. + fn contract_value_taking_bound_keys(refers_to: Value, takes_bound_keys: bool) -> Value { + let mut value = platform_value!({ + "$formatVersion": "1", + "id": Identifier::from_string("4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", Encoding::Base58).expect("a valid id"), + "ownerId": Identifier::from_string("2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", Encoding::Base58).expect("a valid id"), + "version": 1, + "documentSchemas": { + "joinRequest": { + "type": "object", + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": refers_to + }, + "recipientKeyId": { + "type": "integer", + "minimum": 0, + "position": 1 + } + }, + "required": [], + "additionalProperties": false + }, + "submittedCharter": { + "type": "object", + "properties": { + "title": { + "type": "string", + "maxLength": 64, + "position": 0 + } + }, + "required": [], + "additionalProperties": false + } + } + }); + if takes_bound_keys { + for document_type_name in ["joinRequest", "submittedCharter"] { + let path = format!("documentSchemas.{document_type_name}"); + let document_schema = value + .get_mut_value_at_path(&path) + .expect("the document schema"); + for keyword in [ + "requiresIdentityEncryptionBoundedKey", + "requiresIdentityDecryptionBoundedKey", + ] { + document_schema + .insert(keyword.to_string(), Value::U8(2)) + .expect("the keyword inserts"); + } + } + } + value + } + + fn recipient_id_target(contract: &DataContract) -> DocumentPropertyType { + contract + .document_type_for_name("joinRequest") + .expect("the joinRequest document type") + .flattened_properties() + .get("recipientId") + .map(|p| p.property_type.clone()) + .expect("the recipientId property") + } + + #[test] + fn should_accept_bound_to_naming_a_document_type_of_the_contract() { + let platform_version = PlatformVersion::latest(); + + for bound_to in ["submittedCharter", "joinRequest"] { + let contract = DataContract::from_value( + contract_value(platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": bound_to } + })), + true, + platform_version, + ) + .expect("the contract should parse"); + + assert_eq!( + recipient_id_target(&contract), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: IdentityKeyReferenceRequirements { + purpose: Some(Purpose::DECRYPTION), + bound_to: Some(bound_to.to_string()), + }, + } + ) + ); + } + } + + /// The invalid contract structure message a contract value is refused with under full + /// validation; without full validation (a contract read back from state) it parses. + fn refusal_message(value: Value) -> String { + let platform_version = PlatformVersion::latest(); + + DataContract::from_value(value.clone(), false, platform_version) + .expect("a contract read back from state is not re-checked"); + + let error = DataContract::from_value(value, true, platform_version) + .expect_err("the contract should be refused"); + let ProtocolError::ConsensusError(consensus_error) = &error else { + panic!("expected a consensus error, got {error}"); + }; + let ConsensusError::BasicError(BasicError::ContractError( + DataContractError::InvalidContractStructure(message), + )) = consensus_error.deref() + else { + panic!("expected an invalid contract structure error, got {consensus_error}"); + }; + message.clone() + } + + #[test] + fn should_reject_bound_to_naming_a_document_type_the_contract_does_not_have() { + assert_eq!( + refusal_message(contract_value(platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": "electedCharter" } + }))), + "joinRequest.recipientId refersTo keyRequirements boundTo \"electedCharter\" names no document type of this contract" + ); + } + + #[test] + fn should_reject_a_bound_to_no_key_could_ever_carry() { + // Only authentication, encryption and decryption keys carry a document type bound + for purpose in ["transfer", "voting", "owner"] { + let message = refusal_message(contract_value(platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": purpose, "boundTo": "submittedCharter" } + }))); + assert!( + message.starts_with(&format!( + "joinRequest.recipientId refersTo keyRequirements requires a {purpose} key bound to document type \"submittedCharter\", which no key can be" + )), + "{purpose}: {message}" + ); + } + + // An encryption or decryption key is bound to a document type only where the type + // declares that it takes such keys + for purpose in ["encryption", "decryption"] { + let message = refusal_message(contract_value_taking_bound_keys( + platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": purpose, "boundTo": "submittedCharter" } + }), + false, + )); + assert!( + message.contains("which no key can be"), + "{purpose}: {message}" + ); + } + + // Whereas an authentication key, or a key of any purpose, can be bound to a type that + // declares nothing + for requirements in [ + platform_value!({ "purpose": "authentication", "boundTo": "submittedCharter" }), + platform_value!({ "boundTo": "submittedCharter" }), + ] { + DataContract::from_value( + contract_value_taking_bound_keys( + platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": requirements + }), + false, + ), + true, + PlatformVersion::latest(), + ) + .expect("an authentication key can carry any document type bound"); + } + } + + #[test] + fn should_refuse_key_requirements_before_protocol_version_14_and_accept_them_at_it() { + let value = contract_value(platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } + })); + + // The meta-schema of protocol version 13 knows no `refersTo` at all + let platform_version_13 = PlatformVersion::get(13).expect("platform version 13 exists"); + DataContract::from_value(value.clone(), true, platform_version_13) + .expect_err("a contract with keyRequirements should be refused before version 14"); + + let contract = DataContract::from_value(value, true, PlatformVersion::latest()) + .expect("a contract with keyRequirements should parse at version 14"); + assert!(matches!( + recipient_id_target(&contract), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { key_requirements, .. } + ) if key_requirements.purpose == Some(Purpose::DECRYPTION) + && key_requirements.bound_to.as_deref() == Some("submittedCharter") + )); + } + + /// The requirements ride on the schema, which is what the contract serializes, so a + /// contract with them and one without both come back as parsed, and the one without + /// serializes exactly as it did before the keyword existed. + #[test] + fn should_round_trip_key_requirements_through_platform_serialization() { + let platform_version = PlatformVersion::latest(); + + for refers_to in [ + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId" }), + platform_value!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } + }), + ] { + let contract = + DataContract::from_value(contract_value(refers_to), true, platform_version) + .expect("the contract should parse"); + + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize"); + let deserialized = DataContract::versioned_deserialize_untrusted( + bytes.as_slice(), + false, + platform_version, + ) + .expect("the contract should deserialize"); + + assert_eq!( + recipient_id_target(&deserialized), + recipient_id_target(&contract) + ); + assert_eq!( + deserialized + .serialize_to_bytes_with_platform_version(platform_version) + .expect("the contract should serialize again"), + bytes + ); + } + } +} diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs index d356c60ecf2..d4020d5aca3 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/mod.rs @@ -9,10 +9,11 @@ use crate::data_contract::document_type::{ property_names, ContractReferenceModeration, ContractReferenceOwner, ContractReferenceRequirements, DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentType, EncryptedFor, - EncryptedForRecipient, EncryptionScheme, + EncryptedForRecipient, EncryptionScheme, IdentityKeyReferenceRequirements, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; +use crate::identity::Purpose; use crate::util::json_schema::resolve_uri; use crate::validation::operations::ProtocolValidationOperation; use crate::ProtocolError; @@ -585,6 +586,16 @@ fn apply_property_reference_v0( ))); } + // Requirements on the referenced key belong to identity key references alone + if reference_type != "identityPublicKey" + && refers_to_map.contains_key(property_names::KEY_REQUIREMENTS) + { + return Err(DataContractError::InvalidContractStructure(format!( + "{} refersTo does not take keyRequirements", + reference_type + ))); + } + let target = match reference_type { "identity" => DocumentPropertyReferenceTarget::Identity, "contract" => DocumentPropertyReferenceTarget::Contract { @@ -706,6 +717,7 @@ fn apply_property_reference_v0( DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property: key_id_property.to_string(), + key_requirements: parse_identity_key_reference_requirements(&refers_to_map)?, } } other => { @@ -1076,6 +1088,71 @@ fn parse_contract_reference_requirements( Ok(fields) } +/// The `keyRequirements` of an `identityPublicKey` reference: each key an aspect of the +/// referenced key with a closed set of values (`purpose`) or a document type name of the +/// declaring contract (`boundTo`), at least one when the object is given at all. That `boundTo` +/// names a document type the contract has is checked once every document type is parsed, in +/// `create_document_types_from_document_schemas`. +fn parse_identity_key_reference_requirements( + refers_to_map: &BTreeMap, +) -> Result { + let Some(fields_value) = refers_to_map.get(property_names::KEY_REQUIREMENTS) else { + return Ok(IdentityKeyReferenceRequirements::default()); + }; + let fields_map = fields_value.to_btree_ref_string_map()?; + if fields_map.is_empty() { + return Err(DataContractError::InvalidContractStructure( + "identityPublicKey refersTo keyRequirements must declare at least one requirement" + .to_string(), + )); + } + let mut fields = IdentityKeyReferenceRequirements::default(); + for (field, value) in fields_map { + match field.as_str() { + property_names::PURPOSE => { + let name = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "identityPublicKey refersTo keyRequirements purpose must be a string" + .to_string(), + ) + })?; + // The purposes a user's key can carry: every one but SYSTEM + let purpose = Purpose::from_wire_name(name) + .filter(|purpose| Purpose::full_range().contains(purpose)) + .ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "identityPublicKey refersTo keyRequirements purpose {name:?} is unknown, expected one of {:?}", + Purpose::full_range().map(|purpose| purpose.wire_name()) + )) + })?; + fields.purpose = Some(purpose); + } + property_names::BOUND_TO => { + let document_type_name = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "identityPublicKey refersTo keyRequirements boundTo must be a string" + .to_string(), + ) + })?; + if document_type_name.is_empty() || document_type_name.len() > 64 { + return Err(DataContractError::InvalidContractStructure( + "identityPublicKey refersTo keyRequirements boundTo must be between 1 \ + and 64 characters" + .to_string(), + )); + } + fields.bound_to = Some(document_type_name.to_string()); + } + other => { + return Err(DataContractError::InvalidContractStructure(format!( + "identityPublicKey refersTo keyRequirements {other:?} is unknown" + ))); + } + } + } + Ok(fields) +} + /// A duration requirement of a `contract` reference (`minimumAgeSeconds`, /// `minimumSecondsSinceUpdate`): a whole number of seconds from 1 to `u32::MAX`. fn parse_contract_reference_seconds(field: &str, value: &Value) -> Result { @@ -2204,11 +2281,160 @@ mod tests { DocumentPropertyType::IdentifierWithReference( DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property: "toKeyIndex".to_string(), + key_requirements: IdentityKeyReferenceRequirements::default(), } ) ); } + fn identity_key_reference_schema(refers_to: serde_json::Value) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": refers_to + }, + "recipientKeyId": { + "type": "integer", + "position": 1 + } + }, + "required": [], + "additionalProperties": false + }) + } + + fn identity_key_reference_target(refers_to: serde_json::Value) -> DocumentPropertyType { + try_document_type_from_schema(identity_key_reference_schema(refers_to)) + .expect("should parse") + .as_ref() + .flattened_properties() + .get("recipientId") + .map(|p| p.property_type.clone()) + .expect("property should be present") + } + + #[test] + fn should_parse_identity_public_key_refers_to_with_key_requirements() { + assert_eq!( + identity_key_reference_target(json!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": "decryption", "boundTo": "submittedCharter" } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: IdentityKeyReferenceRequirements { + purpose: Some(Purpose::DECRYPTION), + bound_to: Some("submittedCharter".to_string()), + }, + } + ) + ); + for (name, purpose) in [ + ("authentication", Purpose::AUTHENTICATION), + ("encryption", Purpose::ENCRYPTION), + ("decryption", Purpose::DECRYPTION), + ("transfer", Purpose::TRANSFER), + ("voting", Purpose::VOTING), + ("owner", Purpose::OWNER), + ] { + assert_eq!( + identity_key_reference_target(json!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "purpose": name } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: IdentityKeyReferenceRequirements { + purpose: Some(purpose), + bound_to: None, + }, + } + ) + ); + } + assert_eq!( + identity_key_reference_target(json!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { "boundTo": "joinRequest" } + })), + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: IdentityKeyReferenceRequirements { + purpose: None, + bound_to: Some("joinRequest".to_string()), + }, + } + ) + ); + } + + #[test] + fn should_reject_key_requirements_that_are_empty_unknown_or_on_another_type() { + for (refers_to, fragment) in [ + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": {} }), + "at least one requirement", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "signing" } }), + "is unknown", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "system" } }), + "is unknown", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "DECRYPTION" } }), + "is unknown", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": 2 } }), + "must be a string", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "boundTo": "" } }), + "between 1 and 64 characters", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "boundTo": 1 } }), + "must be a string", + ), + ( + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "securityLevel": "high" } }), + "is unknown", + ), + ( + json!({ "type": "identity", "keyRequirements": { "purpose": "decryption" } }), + "does not take keyRequirements", + ), + ( + json!({ "type": "contract", "keyRequirements": { "purpose": "decryption" } }), + "does not take keyRequirements", + ), + ] { + let err = + try_document_type_from_schema(identity_key_reference_schema(refers_to.clone())) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment), + "{refers_to}: expected {fragment:?}, got {err}" + ); + } + } + #[test] fn should_reject_identity_public_key_refers_to_without_key_id_property() { try_document_type_from_schema(json!({ diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs index 41c70011216..835241b9e46 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/validate_update/common/mod.rs @@ -2061,6 +2061,56 @@ mod tests { } } + #[test] + fn should_return_invalid_result_when_an_identity_key_reference_requirement_changes() { + let platform_version = PlatformVersion::latest(); + + for (old_fields, new_fields, changed_path) in [ + ( + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex" }), + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "decryption" } }), + "/properties/toUserId/refersTo/keyRequirements", + ), + ( + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "decryption" } }), + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex" }), + "/properties/toUserId/refersTo/keyRequirements", + ), + ( + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "decryption" } }), + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "encryption" } }), + "/properties/toUserId/refersTo/keyRequirements/purpose", + ), + ( + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "boundTo": "test" } }), + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "boundTo": "other" } }), + "/properties/toUserId/refersTo/keyRequirements/boundTo", + ), + ( + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "decryption" } }), + platform_value!({ "type": "identityPublicKey", "keyIdProperty": "toKeyIndex", "keyRequirements": { "purpose": "decryption", "boundTo": "test" } }), + "/properties/toUserId/refersTo/keyRequirements/boundTo", + ), + ] { + let old_document_type = + identifier_document_type(Some(old_fields), platform_version); + let new_document_type = + identifier_document_type(Some(new_fields), platform_version); + + let result = old_document_type + .as_ref() + .validate_schema(new_document_type.as_ref(), platform_version) + .expect("failed to validate schema compatibility"); + + assert_matches!( + result.errors.as_slice(), + [ConsensusError::BasicError( + BasicError::IncompatibleDocumentTypeSchemaError(e) + )] if e.property_path() == changed_path + ); + } + } + /// `toUserId` and `delegateId`, two identifier properties, with `distinctFrom` on /// `delegateId` as given. fn distinct_from_document_type( diff --git a/packages/rs-dpp/src/data_contract/document_type/mod.rs b/packages/rs-dpp/src/data_contract/document_type/mod.rs index 210dd32ce14..c78f429ae8f 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -132,6 +132,9 @@ pub(crate) mod property_names { pub const SENDER_KEY: &str = "senderKey"; /// `encryptedFor`: the scheme name, one of `EncryptionScheme::ALL`. pub const SCHEME: &str = "scheme"; + pub const KEY_REQUIREMENTS: &str = "keyRequirements"; + pub const PURPOSE: &str = "purpose"; + pub const BOUND_TO: &str = "boundTo"; pub const DOCUMENTS_COUNTABLE: &str = "documentsCountable"; pub const RANGE_COUNTABLE: &str = "rangeCountable"; /// Doctype-level flag naming the property whose values are summed into diff --git a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs index 20db0b92441..2013f7de1b1 100644 --- a/packages/rs-dpp/src/data_contract/document_type/property/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/property/mod.rs @@ -20,6 +20,9 @@ use crate::data_contract::config::DataContractConfig; use crate::data_contract::document_type::property_names; use crate::data_contract::DataContract; use crate::document::property_names::{CREATOR_ID, OWNER_ID}; +use crate::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use crate::identity::identity_public_key::contract_bounds::ContractBounds; +use crate::identity::{IdentityPublicKey, Purpose}; use crate::prelude::TimestampMillis; use crate::ProtocolError; use array::{ArrayItemType, TypedArrayProperty}; @@ -537,6 +540,166 @@ impl ContractReferenceRequirements { } } +/// What an `identityPublicKey` reference requires of the key it points at, beyond its existence +/// and its not being disabled. +/// +/// Declared as `refersTo: { "type": "identityPublicKey", "keyIdProperty": ..., "keyRequirements": +/// { ... } }`: each key names an aspect of the referenced key and its value the requirement on it. +/// Consensus checks the requirements when the referring document is written, against the key it +/// has already fetched for the existence check, so a requirement costs no further read. An unmet +/// one refuses the write with `ReferencedIdentityKeyRequirementNotMetError` (40136). Keys are +/// added to this object as new requirements arrive (a security level, say); a requirement is +/// never a new reference type. +#[derive( + Debug, PartialEq, Eq, Clone, Default, Serialize, Deserialize, Encode, Decode, DecodeUntrusted, +)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct IdentityKeyReferenceRequirements { + /// The purpose the referenced key must have, spelled by its wire name (`"decryption"`). + /// Any purpose but `SYSTEM`, which no identity key of a user carries. + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "purpose_wire_name" + )] + pub purpose: Option, + /// The document type of the declaring contract the referenced key must be bound to: its + /// contract bounds must be `SingleContractDocumentType` naming the declaring contract and + /// exactly this type. A whole-contract bound or a contract group bound never meets it, even + /// where the group holds the type: the check reads nothing beyond the key. Validated when + /// the contract is registered to name a document type of the declaring contract that a key + /// of the required purpose can be bound to, so the check never needs a second contract + /// fetch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bound_to: Option, +} + +/// Serde for [`IdentityKeyReferenceRequirements::purpose`]: the purpose's wire name, not the +/// number `Purpose` serializes to on an identity key, so the value matches the schema keyword. +mod purpose_wire_name { + use crate::identity::Purpose; + use serde::de::Error; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize( + purpose: &Option, + serializer: S, + ) -> Result { + purpose + .as_ref() + .map(Purpose::wire_name) + .serialize(serializer) + } + + pub fn deserialize<'de, D: Deserializer<'de>>( + deserializer: D, + ) -> Result, D::Error> { + let name: Option = Option::deserialize(deserializer)?; + name.map(|name| { + // The purposes a user's key can carry, every one but SYSTEM, as the schema + // parser admits them + Purpose::from_wire_name(&name) + .filter(|purpose| Purpose::full_range().contains(purpose)) + .ok_or_else(|| D::Error::custom(format!("unknown key purpose {name:?}"))) + }) + .transpose() + } +} + +/// One requirement of an [`IdentityKeyReferenceRequirements`] declaration, named the way the +/// declaration spells it, for the error that reports it unmet. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum IdentityKeyReferenceRequirement<'a> { + Purpose(Purpose), + BoundTo(&'a str), +} + +impl IdentityKeyReferenceRequirement<'_> { + /// The `keyRequirements` key the requirement was declared under. + pub fn field(&self) -> &'static str { + match self { + IdentityKeyReferenceRequirement::Purpose(_) => property_names::PURPOSE, + IdentityKeyReferenceRequirement::BoundTo(_) => property_names::BOUND_TO, + } + } + + /// The value the declaration requires, as spelled in the schema. + pub fn required(&self) -> String { + match self { + IdentityKeyReferenceRequirement::Purpose(purpose) => purpose.wire_name().to_string(), + IdentityKeyReferenceRequirement::BoundTo(document_type_name) => { + document_type_name.to_string() + } + } + } + + /// Whether `key`, a key of an identity, meets this requirement for a reference declared by + /// the contract `declaring_contract_id`. + pub fn is_met_by(&self, key: &IdentityPublicKey, declaring_contract_id: Identifier) -> bool { + match self { + IdentityKeyReferenceRequirement::Purpose(purpose) => key.purpose() == *purpose, + IdentityKeyReferenceRequirement::BoundTo(document_type_name) => matches!( + key.contract_bounds(), + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: bound_document_type_name, + }) if *id == declaring_contract_id && bound_document_type_name == document_type_name + ), + } + } + + /// What `key` has where the declaration requires [`Self::required`], for the error that + /// reports the requirement unmet. + pub fn actual_of(&self, key: &IdentityPublicKey) -> String { + match self { + IdentityKeyReferenceRequirement::Purpose(_) => key.purpose().wire_name().to_string(), + IdentityKeyReferenceRequirement::BoundTo(_) => match key.contract_bounds() { + None => "no contract bounds".to_string(), + Some(ContractBounds::SingleContract { id }) => { + format!("whole contract {id}, not a document type") + } + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) => format!("contract {id} document type {document_type_name}"), + Some(ContractBounds::ContractGroup { id }) => { + format!("contract group {id}, which never meets a document type bound") + } + }, + } + } +} + +impl IdentityKeyReferenceRequirements { + /// Whether the declaration requires nothing beyond the key's existence. + pub fn is_empty(&self) -> bool { + self.purpose.is_none() && self.bound_to.is_none() + } + + /// The requirements, in declaration order. + pub fn requirements(&self) -> impl Iterator> + '_ { + self.purpose + .into_iter() + .map(IdentityKeyReferenceRequirement::Purpose) + .chain( + self.bound_to + .as_deref() + .map(IdentityKeyReferenceRequirement::BoundTo), + ) + } + + /// The first requirement `key` does not meet for a reference declared by the contract + /// `declaring_contract_id`, `None` when it meets them all. + pub fn first_unmet_by( + &self, + key: &IdentityPublicKey, + declaring_contract_id: Identifier, + ) -> Option> { + self.requirements() + .find(|requirement| !requirement.is_met_by(key, declaring_contract_id)) + } +} + // This enum is embedded in consensus errors, so it is consensus-serialized. // @append_only #[derive( @@ -600,14 +763,22 @@ pub enum DocumentPropertyReferenceTarget { /// identity id and the named sibling property of the same document type /// holds the key id. Identity keys can be disabled but never removed, so /// an existing reference can never dangle; at write time the key must - /// exist and must not be disabled. The referenced key is the (identity - /// id, key id) pair, so a replace that changes either property - /// re-validates the reference. + /// exist, must not be disabled and must meet the declared + /// [`IdentityKeyReferenceRequirements`], if any. The referenced key is + /// the (identity id, key id) pair, so a replace that changes either + /// property re-validates the reference. #[serde(rename = "identityPublicKey")] IdentityPublicKey { /// The property of the same document type whose value carries the /// referenced key id key_id_property: String, + /// What the referenced key must be beyond existing: a purpose, a + /// binding to a document type of the declaring contract + #[serde( + default, + skip_serializing_if = "IdentityKeyReferenceRequirements::is_empty" + )] + key_requirements: IdentityKeyReferenceRequirements, }, /// A document of a document type whose documents CAN be deleted: the /// counterpart of [`Self::PermanentDocument`], disjoint from it, so a @@ -776,8 +947,18 @@ impl std::fmt::Display for DocumentPropertyReferenceTarget { f, "permanent document (own contract, document type {document_type_name})" ), - DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { - write!(f, "identity public key (key id property {key_id_property})") + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, + key_requirements, + } => { + write!(f, "identity public key (key id property {key_id_property})")?; + if let Some(purpose) = key_requirements.purpose { + write!(f, " with purpose {}", purpose.wire_name())?; + } + if let Some(document_type_name) = &key_requirements.bound_to { + write!(f, " bound to document type {document_type_name}")?; + } + Ok(()) } DocumentPropertyReferenceTarget::DeletableDocument { contract_id: Some(contract_id), @@ -3420,6 +3601,9 @@ fn find_integer_type_for_min_and_max_values(min: i64, max: i64) -> DocumentPrope #[allow(clippy::approx_constant)] mod tests { use super::*; + use crate::identity::identity_public_key::v0::IdentityPublicKeyV0; + use crate::identity::{KeyType, SecurityLevel}; + use platform_value::BinaryData; use platform_version::version::PlatformVersion; // ----------------------------------------------------------------------- @@ -9014,6 +9198,151 @@ mod tests { .is_none()); } + fn key_with(purpose: Purpose, contract_bounds: Option) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 2, + purpose, + security_level: SecurityLevel::HIGH, + contract_bounds, + key_type: KeyType::ECDSA_HASH160, + data: BinaryData::new(vec![0x74; 20]), + read_only: false, + disabled_at: None, + }) + } + + #[test] + fn should_report_the_first_unmet_key_requirement_and_what_the_key_has() { + let contract_id = Identifier::new([3; 32]); + let other_contract_id = Identifier::new([4; 32]); + let requirements = IdentityKeyReferenceRequirements { + purpose: Some(Purpose::DECRYPTION), + bound_to: Some("submittedCharter".to_string()), + }; + let bound_to_charter = |id: Identifier| ContractBounds::SingleContractDocumentType { + id, + document_type_name: "submittedCharter".to_string(), + }; + + assert!(requirements + .first_unmet_by( + &key_with(Purpose::DECRYPTION, Some(bound_to_charter(contract_id))), + contract_id, + ) + .is_none()); + + // The purpose is checked first, whatever the bound + let key = key_with(Purpose::ENCRYPTION, None); + let unmet = requirements + .first_unmet_by(&key, contract_id) + .expect("the purpose is unmet"); + assert_eq!( + unmet, + IdentityKeyReferenceRequirement::Purpose(Purpose::DECRYPTION) + ); + assert_eq!(unmet.field(), "purpose"); + assert_eq!(unmet.required(), "decryption"); + assert_eq!(unmet.actual_of(&key), "encryption"); + + for (key, actual) in [ + (key_with(Purpose::DECRYPTION, None), "no contract bounds"), + ( + key_with( + Purpose::DECRYPTION, + Some(ContractBounds::SingleContract { id: contract_id }), + ), + &format!("whole contract {contract_id}, not a document type"), + ), + ( + key_with( + Purpose::DECRYPTION, + Some(ContractBounds::SingleContractDocumentType { + id: contract_id, + document_type_name: "joinRequest".to_string(), + }), + ), + &format!("contract {contract_id} document type joinRequest"), + ), + ( + key_with( + Purpose::DECRYPTION, + Some(bound_to_charter(other_contract_id)), + ), + &format!("contract {other_contract_id} document type submittedCharter"), + ), + ( + key_with( + Purpose::DECRYPTION, + Some(ContractBounds::ContractGroup { id: contract_id }), + ), + &format!("contract group {contract_id}, which never meets a document type bound"), + ), + ] { + let unmet = requirements + .first_unmet_by(&key, contract_id) + .expect("the bound is unmet"); + assert_eq!( + unmet, + IdentityKeyReferenceRequirement::BoundTo("submittedCharter") + ); + assert_eq!(unmet.field(), "boundTo"); + assert_eq!(unmet.required(), "submittedCharter"); + assert_eq!(unmet.actual_of(&key), actual); + } + + assert!(IdentityKeyReferenceRequirements::default().is_empty()); + assert!(IdentityKeyReferenceRequirements::default() + .first_unmet_by(&key_with(Purpose::ENCRYPTION, None), contract_id) + .is_none()); + } + + #[test] + fn should_serialize_key_requirements_by_their_wire_names() { + let requirements = IdentityKeyReferenceRequirements { + purpose: Some(Purpose::DECRYPTION), + bound_to: Some("submittedCharter".to_string()), + }; + let json = serde_json::to_value(&requirements).expect("serializes"); + assert_eq!( + json, + serde_json::json!({ "purpose": "decryption", "boundTo": "submittedCharter" }) + ); + assert_eq!( + serde_json::from_value::(json).expect("parses"), + requirements + ); + assert_eq!( + serde_json::to_value(IdentityKeyReferenceRequirements::default()).expect("serializes"), + serde_json::json!({}) + ); + for name in ["signing", "system", "DECRYPTION"] { + assert!( + serde_json::from_value::( + serde_json::json!({ "purpose": name }) + ) + .is_err(), + "{name} should not deserialize as a key purpose requirement" + ); + } + assert_eq!( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: requirements, + } + .to_string(), + "identity public key (key id property recipientKeyId) with purpose decryption bound \ + to document type submittedCharter" + ); + assert_eq!( + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property: "recipientKeyId".to_string(), + key_requirements: Default::default(), + } + .to_string(), + "identity public key (key id property recipientKeyId)" + ); + } + /// A compile-time guard, not a behavioural test. /// /// `DocumentPropertyReferenceTarget` is mirrored outside this crate — @@ -9038,6 +9367,7 @@ mod tests { }, DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property: "signerKeyId".to_string(), + key_requirements: Default::default(), }, DocumentPropertyReferenceTarget::DeletableDocument { contract_id: None, diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 541e717adf8..9e5f25d7bde 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -361,6 +361,7 @@ impl ErrorWithCode for StateError { Self::DocumentActionFeeAgreementMismatchError(_) => 40133, Self::DocumentActionFeeMultiplierNotToleratedError(_) => 40134, Self::ReferencedContractRequirementNotMetError(_) => 40135, + Self::ReferencedIdentityKeyRequirementNotMetError(_) => 40136, // Identity Errors: 40200-40299 Self::IdentityAlreadyExistsError(_) => 40200, diff --git a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs index b22abf538ff..7205c31d512 100644 --- a/packages/rs-dpp/src/errors/consensus/state/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/document/mod.rs @@ -28,4 +28,5 @@ pub mod referenced_document_type_not_found_error; pub mod referenced_entity_not_found_error; pub mod referenced_identity_key_disabled_error; pub mod referenced_identity_key_not_found_error; +pub mod referenced_identity_key_requirement_not_met_error; pub mod referenced_key_id_property_invalid_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_requirement_not_met_error.rs b/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_requirement_not_met_error.rs new file mode 100644 index 00000000000..645603a1ede --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/document/referenced_identity_key_requirement_not_met_error.rs @@ -0,0 +1,105 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::identity::KeyID; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use platform_value::Identifier; +use thiserror::Error; + +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "referenced public key {key_id} of identity {identity_id} for {document_type_name}.{path} has {field} {actual}, the reference requires {required}" +)] +#[platform_serialize(unversioned)] +pub struct ReferencedIdentityKeyRequirementNotMetError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + document_type_name: String, + path: String, + identity_id: Identifier, + key_id: KeyID, + field: String, + required: String, + actual: String, +} + +impl ReferencedIdentityKeyRequirementNotMetError { + pub fn new( + document_type_name: String, + path: String, + identity_id: Identifier, + key_id: KeyID, + field: String, + required: String, + actual: String, + ) -> Self { + Self { + document_type_name, + path, + identity_id, + key_id, + field, + required, + actual, + } + } + + /// The document type declaring the reference + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } + + /// The referring property + pub fn path(&self) -> &str { + &self.path + } + + /// The identity whose key was referenced + pub fn identity_id(&self) -> &Identifier { + &self.identity_id + } + + /// The referenced key, which exists and is enabled but does not meet the requirement + pub fn key_id(&self) -> KeyID { + self.key_id + } + + /// The `keyRequirements` key of the requirement, `purpose` or `boundTo` + pub fn field(&self) -> &str { + &self.field + } + + /// The value the reference requires, `decryption` for one + pub fn required(&self) -> &str { + &self.required + } + + /// What the key has instead, `encryption` for one + pub fn actual(&self) -> &str { + &self.actual + } +} + +impl From for ConsensusError { + fn from(err: ReferencedIdentityKeyRequirementNotMetError) -> Self { + Self::StateError(StateError::ReferencedIdentityKeyRequirementNotMetError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index a2793685e97..bad77898bd2 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -72,6 +72,7 @@ use crate::consensus::state::document::referenced_contract_requirement_not_met_e use crate::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use crate::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use crate::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; +use crate::consensus::state::document::referenced_identity_key_requirement_not_met_error::ReferencedIdentityKeyRequirementNotMetError; use crate::consensus::state::document::referenced_document_property_agreement_invalid_error::ReferencedDocumentPropertyAgreementInvalidError; use crate::consensus::state::document::referenced_document_property_mismatch_error::ReferencedDocumentPropertyMismatchError; use crate::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; @@ -587,6 +588,10 @@ pub enum StateError { // Requirements on a referenced contract (protocol version 14). #[error(transparent)] ReferencedContractRequirementNotMetError(ReferencedContractRequirementNotMetError), + + // Requirements on a referenced identity key (protocol version 14). + #[error(transparent)] + ReferencedIdentityKeyRequirementNotMetError(ReferencedIdentityKeyRequirementNotMetError), } impl From for ConsensusError { @@ -1086,7 +1091,7 @@ mod tests { )), 142 ); - // Requirements on a referenced contract (protocol version 14): the tail of the enum. + // Requirements on a referenced contract (protocol version 14). assert_eq!( discriminant_of(StateError::ReferencedContractRequirementNotMetError( ReferencedContractRequirementNotMetError::new( @@ -1098,5 +1103,20 @@ mod tests { )), 143 ); + // Requirements on a referenced identity key (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::ReferencedIdentityKeyRequirementNotMetError( + ReferencedIdentityKeyRequirementNotMetError::new( + "joinRequest".to_string(), + "recipientId".to_string(), + group_id, + 2, + "purpose".to_string(), + "decryption".to_string(), + "encryption".to_string(), + ) + )), + 144 + ); } } diff --git a/packages/rs-dpp/src/identity/identity_public_key/purpose.rs b/packages/rs-dpp/src/identity/identity_public_key/purpose.rs index 18f0b6e20c0..3e593a9dff9 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/purpose.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/purpose.rs @@ -129,6 +129,34 @@ impl Purpose { pub fn encryption_decryption() -> [Purpose; 2] { [ENCRYPTION, DECRYPTION] } + + /// The name a schema keyword spells the purpose with: the variant's own name in lower case, + /// `"decryption"` for one. + pub fn wire_name(&self) -> &'static str { + match self { + AUTHENTICATION => "authentication", + ENCRYPTION => "encryption", + DECRYPTION => "decryption", + TRANSFER => "transfer", + SYSTEM => "system", + VOTING => "voting", + OWNER => "owner", + } + } + + /// The purpose a wire name names, `None` for any other name. + pub fn from_wire_name(name: &str) -> Option { + match name { + "authentication" => Some(AUTHENTICATION), + "encryption" => Some(ENCRYPTION), + "decryption" => Some(DECRYPTION), + "transfer" => Some(TRANSFER), + "system" => Some(SYSTEM), + "voting" => Some(VOTING), + "owner" => Some(OWNER), + _ => None, + } + } } #[cfg(test)] @@ -259,6 +287,26 @@ mod tests { } } + // -- wire names -- + #[test] + fn should_round_trip_every_purpose_through_its_wire_name() { + for purpose in [ + AUTHENTICATION, + ENCRYPTION, + DECRYPTION, + TRANSFER, + SYSTEM, + VOTING, + OWNER, + ] { + let name = purpose.wire_name(); + assert_eq!(name, format!("{purpose:?}").to_lowercase()); + assert_eq!(Purpose::from_wire_name(name), Some(purpose)); + } + assert_eq!(Purpose::from_wire_name("AUTHENTICATION"), None); + assert_eq!(Purpose::from_wire_name("signing"), None); + } + // -- ordering -- #[test] fn test_purpose_ordering_matches_discriminant() { diff --git a/packages/rs-dpp/src/validation/meta_validators/mod.rs b/packages/rs-dpp/src/validation/meta_validators/mod.rs index 097c3b3fe4c..1565f9fbf55 100644 --- a/packages/rs-dpp/src/validation/meta_validators/mod.rs +++ b/packages/rs-dpp/src/validation/meta_validators/mod.rs @@ -404,6 +404,51 @@ mod tests { } } + #[test] + fn should_accept_key_requirements_on_an_identity_public_key_refers_to_in_v3_document_schema() { + for requirements in [ + json!({ "purpose": "decryption" }), + json!({ "purpose": "authentication" }), + json!({ "purpose": "owner" }), + json!({ "boundTo": "submittedCharter" }), + json!({ "purpose": "decryption", "boundTo": "submittedCharter" }), + ] { + let schema = document_schema_with_refers_to(json!({ + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": requirements + })); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_ok(), + "expected keyRequirements {requirements} to be valid" + ); + } + } + + #[test] + fn should_reject_malformed_key_requirements_in_v3_document_schema() { + for refers_to in [ + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": {} }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "system" } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": "DECRYPTION" } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "purpose": 2 } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "boundTo": "" } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "boundTo": "a.b" } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "boundTo": "a-b" } }), + json!({ "type": "identityPublicKey", "keyIdProperty": "recipientKeyId", "keyRequirements": { "securityLevel": "high" } }), + json!({ "type": "identity", "keyRequirements": { "purpose": "decryption" } }), + json!({ "type": "contract", "keyRequirements": { "purpose": "decryption" } }), + ] { + let schema = document_schema_with_refers_to(refers_to.clone()); + + assert!( + DOCUMENT_META_SCHEMA_V3.validate(&schema).is_err(), + "expected refersTo {refers_to} to be invalid" + ); + } + } + #[test] fn should_accept_permanent_document_refers_to_in_v3_document_schema() { let schema = document_schema_with_refers_to(json!({ diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs index 6487aa68c26..9fdff64d40a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_reference_validation/v0/mod.rs @@ -25,6 +25,7 @@ use dpp::errors::consensus::state::document::referenced_contract_requirement_not use dpp::errors::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use dpp::errors::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use dpp::errors::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; +use dpp::errors::consensus::state::document::referenced_identity_key_requirement_not_met_error::ReferencedIdentityKeyRequirementNotMetError; use dpp::errors::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; use dpp::identifier::Identifier; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -246,9 +247,9 @@ fn validate_document_type_references_v0( // it is checked against the new target, or not at all once // the reference is cleared. DocumentPropertyReferenceTarget::DeletableDocument { .. } => true, - DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { - is_changed_field(changed, key_id_property) - } + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, .. + } => is_changed_field(changed, key_id_property), DocumentPropertyReferenceTarget::Identity | DocumentPropertyReferenceTarget::Contract { .. } | DocumentPropertyReferenceTarget::Token => false, @@ -568,7 +569,10 @@ fn validate_document_type_references_v0( referenced_document.is_some() } - DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, + key_requirements, + } => { // The referenced key id is carried by the named sibling property let key_id: KeyID = match document_data.get_optional_integer_at_path(key_id_property) { @@ -632,6 +636,26 @@ fn validate_document_type_references_v0( )); } + // The declaration's requirements are checked against the key just + // fetched, so they cost no further read; the first unmet one refuses + // the write. A bound names the declaring contract and one of its own + // document types (checked when the contract was registered), so the + // check needs nothing beyond the key and the contract in hand + if let Some(requirement) = key_requirements.first_unmet_by(&key, contract.id()) { + return Ok(SimpleConsensusValidationResult::new_with_error( + ReferencedIdentityKeyRequirementNotMetError::new( + document_type.name().to_string(), + path.to_string(), + Identifier::from(referenced_id), + key_id, + requirement.field().to_string(), + requirement.required(), + requirement.actual_of(&key), + ) + .into(), + )); + } + true } }; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 30a8ba16ded..7847416a475 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -6990,4 +6990,251 @@ mod creation_tests { } ); } + + /// Registers the key-requirements fixture contract, adds the keys of + /// [`IdentityKeyRequirementTargets`] to the test identity, then creates a `message` + /// document mutated by the test and returns the execution result. + async fn run_identity_key_requirement_creation(mutator: F) -> StateTransitionExecutionResult + where + F: FnOnce(&mut Document, &IdentityKeyRequirementTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(433); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_IDENTITY_KEY_REQUIREMENTS_CONTRACT_PATH, + None, + None, + None::, + None, + None, + ); + + let targets = add_identity_key_requirement_targets( + &mut platform, + &identity, + key.id(), + contract.id(), + platform_version, + ); + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + mutator(&mut document, &targets); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone() + } + + #[tokio::test] + async fn should_document_creation_succeed_when_referenced_key_meets_its_requirements() { + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set( + "recipientKeyId", + (targets.decryption_key_bound_to_inbox_id as i64).into(), + ); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_key_has_the_wrong_purpose() { + let cases: [(fn(&IdentityKeyRequirementTargets) -> KeyID, &str); 2] = [ + ( + |t: &IdentityKeyRequirementTargets| t.encryption_key_bound_to_inbox_id, + "encryption", + ), + ( + |t: &IdentityKeyRequirementTargets| t.authentication_key_id, + "authentication", + ), + ]; + for (key_id, actual) in cases { + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set("recipientKeyId", (key_id(targets) as i64).into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedIdentityKeyRequirementNotMetError(ref e) + ), + .. + } if e.document_type_name() == "message" + && e.path() == "recipientId" + && e.field() == "purpose" + && e.required() == "decryption" + && e.actual() == actual + ); + } + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_key_is_bound_to_another_document_type() { + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set( + "recipientKeyId", + (targets.decryption_key_bound_to_message_id as i64).into(), + ); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedIdentityKeyRequirementNotMetError(ref e) + ), + .. + } if e.document_type_name() == "message" + && e.path() == "recipientId" + && e.key_id() == 4 + && e.field() == "boundTo" + && e.required() == "inbox" + && e.actual().ends_with(" document type message") + ); + } + + #[tokio::test] + async fn should_document_creation_fail_when_referenced_key_has_no_contract_bounds() { + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set( + "recipientKeyId", + (targets.unbound_decryption_key_id as i64).into(), + ); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedIdentityKeyRequirementNotMetError(ref e) + ), + .. + } if e.field() == "boundTo" + && e.required() == "inbox" + && e.actual() == "no contract bounds" + ); + } + + #[tokio::test] + async fn should_document_creation_fail_as_key_not_found_when_required_key_is_missing() { + // A key that does not exist is still reported as missing, not as unmet + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set("recipientKeyId", 99i64.into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedIdentityKeyNotFoundError( + _ + )), + .. + } + ); + + // And an unset key id property is still reported as invalid + let result = run_identity_key_requirement_creation(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError(StateError::ReferencedKeyIdPropertyInvalidError( + _ + )), + .. + } + ); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs index 81b3f6f2c68..febb3f31f52 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/mod.rs @@ -21,3 +21,105 @@ mod transfer; use super::*; use crate::execution::validation::state_transition::tests::create_card_game_internal_token_contract_with_owner_identity_burn_tokens; + +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::TempPlatform; +use dpp::block::block_info::BlockInfo; +use dpp::identifier::Identifier; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::contract_bounds::ContractBounds; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::version::PlatformVersion; + +pub(super) const REFERENCE_VALIDATION_IDENTITY_KEY_REQUIREMENTS_CONTRACT_PATH: &str = + "tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-requirements.json"; + +/// The keys of the test identity the key-requirement tests can point at. The fixture's +/// `message.recipientId` requires a decryption key bound to the fixture contract's +/// `inbox` document type. Both fixture types declare +/// `requiresIdentityEncryptionBoundedKey` and `requiresIdentityDecryptionBoundedKey`, +/// without which Drive registers no encryption or decryption key bound to them. +pub(super) struct IdentityKeyRequirementTargets { + pub(super) identity_id: Identifier, + /// The critical authentication key the identity registered with: the wrong purpose + pub(super) authentication_key_id: KeyID, + /// A decryption key bound to (fixture contract, `inbox`): meets both requirements + pub(super) decryption_key_bound_to_inbox_id: KeyID, + /// An encryption key bound to (fixture contract, `inbox`): the wrong purpose + pub(super) encryption_key_bound_to_inbox_id: KeyID, + /// A decryption key bound to (fixture contract, `message`): the wrong document type + pub(super) decryption_key_bound_to_message_id: KeyID, + /// A decryption key without contract bounds + pub(super) unbound_decryption_key_id: KeyID, +} + +/// Adds the four keys of [`IdentityKeyRequirementTargets`] to `identity` in state, bound +/// to `contract_id` where bound, and returns the targets. +pub(super) fn add_identity_key_requirement_targets( + platform: &mut TempPlatform, + identity: &Identity, + authentication_key_id: KeyID, + contract_id: Identifier, + platform_version: &PlatformVersion, +) -> IdentityKeyRequirementTargets { + let key = |id: KeyID, purpose: Purpose, contract_bounds: Option| { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::HIGH, + contract_bounds, + key_type: KeyType::ECDSA_HASH160, + data: BinaryData::new(vec![0x70 + id as u8; 20]), + read_only: false, + disabled_at: None, + }) + }; + let bound_to = |document_type_name: &str| { + Some(ContractBounds::SingleContractDocumentType { + id: contract_id, + document_type_name: document_type_name.to_string(), + }) + }; + + let targets = IdentityKeyRequirementTargets { + identity_id: identity.id(), + authentication_key_id, + decryption_key_bound_to_inbox_id: 2, + encryption_key_bound_to_inbox_id: 3, + decryption_key_bound_to_message_id: 4, + unbound_decryption_key_id: 5, + }; + + platform + .drive + .add_new_non_unique_keys_to_identity( + identity.id().to_buffer(), + vec![ + key( + targets.decryption_key_bound_to_inbox_id, + Purpose::DECRYPTION, + bound_to("inbox"), + ), + key( + targets.encryption_key_bound_to_inbox_id, + Purpose::ENCRYPTION, + bound_to("inbox"), + ), + key( + targets.decryption_key_bound_to_message_id, + Purpose::DECRYPTION, + bound_to("message"), + ), + key(targets.unbound_decryption_key_id, Purpose::DECRYPTION, None), + ], + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add the keys to the identity"); + + targets +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 0bbf619b864..fc5a0c79a7b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -3417,6 +3417,230 @@ mod replacement_tests { .clone() } + /// Registers the key-requirements fixture contract, adds the keys of + /// [`IdentityKeyRequirementTargets`] to the test identity, creates a `message` + /// document referencing the key that meets the requirements (asserting success), + /// then replaces it shaped by `replace_mutator` and returns the replace execution + /// result. + async fn run_identity_key_requirement_create_then_replace( + replace_mutator: R, + ) -> StateTransitionExecutionResult + where + R: FnOnce(&mut Document, &IdentityKeyRequirementTargets), + { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(433); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + let contract = setup_contract( + &platform.drive, + REFERENCE_VALIDATION_IDENTITY_KEY_REQUIREMENTS_CONTRACT_PATH, + None, + None, + None::, + None, + None, + ); + + let targets = add_identity_key_requirement_targets( + &mut platform, + &identity, + key.id(), + contract.id(), + platform_version, + ); + + let message = contract + .document_type_for_name("message") + .expect("expected a message document type"); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let mut document = message + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random message document"); + document + .set_id_for_creation(message, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + + document.set("recipientId", targets.identity_id.into()); + document.set( + "recipientKeyId", + (targets.decryption_key_bound_to_inbox_id as i64).into(), + ); + document.set("note", "hello".into()); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document.clone(), + message, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_create_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + 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"); + + document.increment_revision().unwrap(); + replace_mutator(&mut document, &targets); + + let documents_batch_replace_transition = + BatchTransition::new_document_replacement_transition_from_document( + document, + message, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_replace_serialized_transition = documents_batch_replace_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &[documents_batch_replace_serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + processing_result + .execution_results() + .first() + .expect("expected one execution result") + .clone() + } + + /// keyRequirements on replace: repointing the reference at a key that + /// fails a requirement is refused, through the key id alone. + #[tokio::test] + async fn should_document_replace_fail_when_repointed_at_a_key_that_fails_the_requirement() { + let result = run_identity_key_requirement_create_then_replace(|document, targets| { + document.set( + "recipientKeyId", + (targets.encryption_key_bound_to_inbox_id as i64).into(), + ); + }) + .await; + + assert_matches!( + result, + PaidConsensusError { + error: ConsensusError::StateError( + StateError::ReferencedIdentityKeyRequirementNotMetError(ref e) + ), + .. + } if e.document_type_name() == "message" + && e.path() == "recipientId" + && e.field() == "purpose" + && e.required() == "decryption" + && e.actual() == "encryption" + ); + } + + /// keyRequirements on replace: a replace that leaves the reference and its + /// key id alone is not re-checked, and one that repoints it at another key + /// meeting the requirements passes. + #[tokio::test] + async fn should_document_replace_succeed_when_the_reference_is_untouched_or_still_met() { + let result = run_identity_key_requirement_create_then_replace(|document, _| { + document.set("note", "changed".into()); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = run_identity_key_requirement_create_then_replace(|document, targets| { + document.set("recipientId", targets.identity_id.into()); + document.set( + "recipientKeyId", + (targets.decryption_key_bound_to_inbox_id as i64).into(), + ); + document.set("note", "changed".into()); + }) + .await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } + /// identityPublicKey on replace: changing only the key id property while /// leaving the identity id untouched must re-validate the reference: /// the referenced key is the (identity id, key id) pair, so the diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs index 3ee33668bbc..1790ae164dd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/data_contract_common/data_contract_reference_validation/v0/mod.rs @@ -89,8 +89,9 @@ pub(super) fn validate_data_contract_references_v0( // The key id property must exist in the same document type and be // an integer; nothing else about the declaration is state-dependent - if let DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } = - reference_target + if let DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, .. + } = reference_target { match document_type .as_ref() diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-requirements.json b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-requirements.json new file mode 100644 index 00000000000..de3f40e33dd --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/reference-validation/reference-validation-contract-identity-key-requirements.json @@ -0,0 +1,58 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVd", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "documentSchemas": { + "message": { + "type": "object", + "documentsMutable": true, + "requiresIdentityEncryptionBoundedKey": 2, + "requiresIdentityDecryptionBoundedKey": 2, + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0, + "refersTo": { + "type": "identityPublicKey", + "keyIdProperty": "recipientKeyId", + "keyRequirements": { + "purpose": "decryption", + "boundTo": "inbox" + } + } + }, + "recipientKeyId": { + "type": "integer", + "position": 1, + "minimum": 0 + }, + "note": { + "type": "string", + "position": 2, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + }, + "inbox": { + "type": "object", + "requiresIdentityEncryptionBoundedKey": 2, + "requiresIdentityDecryptionBoundedKey": 2, + "properties": { + "label": { + "type": "string", + "position": 0, + "maxLength": 64 + } + }, + "required": [], + "additionalProperties": false + } + } +} diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 55f8a239a57..142b79c58f0 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -624,7 +624,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// the ciphertext is verifiable on chain. A changed `encryptedFor` is an /// incompatible schema change on update. /// -/// 27. **Property and document type names are word characters only**: +/// 28. **Property and document type names are word characters only**: /// meta-schema v3 refuses `-` in a property name (top-level or nested, /// and in the property paths of `refersTo` declarations) and generation /// 3 of the document type parser refuses it in a document type name, @@ -634,6 +634,26 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// and testnet (2026-09-23) found no name carrying one, so nothing stored /// is affected. Stored contracts are read as they are. /// +/// 29. **Identity key references may require a purpose and a document type +/// bound**: an `identityPublicKey` `refersTo` declaration may carry +/// `keyRequirements`, what the referenced key must be beyond existing and +/// not being disabled, with `purpose` (the key's purpose, by its wire name, +/// any but `system`) and `boundTo` (the key's contract bounds must be +/// exactly the declaring contract and the named document type of it) as +/// the requirements (meta-schema v3, `apply_property_reference` 0, +/// `IdentityKeyReferenceRequirements` on +/// `DocumentPropertyReferenceTarget::IdentityPublicKey`). +/// `create_document_types_from_document_schemas` 1, edited in place (the +/// check is inert before this version, where no parsed reference carries +/// requirements), refuses a contract whose `boundTo` names a document type +/// it does not have or one no key of the required purpose can be bound +/// to, so the check never needs a second contract fetch and a declared +/// requirement can be met. The document reference validation +/// checks the requirements against the key it fetched for the existence +/// check, so they cost no further read, and refuses the first unmet one +/// with `ReferencedIdentityKeyRequirementNotMetError` (40136). A changed +/// `keyRequirements` is an incompatible schema change on update. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index c37181e216b..dc4a35252a8 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -159,6 +159,7 @@ use dpp::consensus::state::shielded::invalid_shielded_proof_error::InvalidShield use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; use dpp::consensus::basic::state_transition::{StateTransitionNotActiveError, TransitionOverMaxInputsError, TransitionOverMaxOutputsError, InputWitnessCountMismatchError, TransitionNoInputsError, TransitionNoOutputsError, FeeStrategyEmptyError, FeeStrategyDuplicateError, FeeStrategyIndexOutOfBoundsError, FeeStrategyTooManyStepsError, InputBelowMinimumError, OutputBelowMinimumError, InputOutputBalanceMismatchError, OutputsNotGreaterThanInputsError, WithdrawalBalanceMismatchError, InsufficientFundingAmountError, InputsNotLessThanOutputsError, OutputAddressAlsoInputError, InvalidRemainderOutputCountError, WithdrawalBelowMinAmountError, ShieldedNoActionsError, ShieldedTooManyActionsError, ShieldedEmptyProofError, ShieldedZeroAnchorError, ShieldedInvalidValueBalanceError, ShieldedEncryptedNoteSizeMismatchError, ShieldedImplicitFeeCapExceededError, ShieldedInvalidDenominationError}; use dpp::consensus::state::document::referenced_contract_requirement_not_met_error::ReferencedContractRequirementNotMetError; +use dpp::consensus::state::document::referenced_identity_key_requirement_not_met_error::ReferencedIdentityKeyRequirementNotMetError; use dpp::consensus::state::voting::masternode_incorrect_voter_identity_id_error::MasternodeIncorrectVoterIdentityIdError; use dpp::consensus::state::voting::masternode_incorrect_voting_address_error::MasternodeIncorrectVotingAddressError; use dpp::consensus::state::voting::masternode_not_found_error::MasternodeNotFoundError; @@ -687,6 +688,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::ReferencedContractRequirementNotMetError(e) => { generic_consensus_error!(ReferencedContractRequirementNotMetError, e).into() } + StateError::ReferencedIdentityKeyRequirementNotMetError(e) => { + generic_consensus_error!(ReferencedIdentityKeyRequirementNotMetError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 54892948e17..c749fbd1ac4 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -58,11 +58,15 @@ pub enum DocumentReferenceErrorCodeWasm { /// document, or a config flag (read-only, keeping history, the owner /// protection of its elected moderation declaration). ReferencedContractRequirementNotMet = 40135, + /// The referenced identity public key exists and is enabled but does not + /// meet what the reference's `keyRequirements` require of it: its + /// purpose, or a binding to a document type of the declaring contract. + ReferencedIdentityKeyRequirementNotMet = 40136, } impl DocumentReferenceErrorCodeWasm { /// The reference-validation error a code names, or `None` when the code - /// is not in the 40120-40125 range, 40131 or 40135. + /// is not in the 40120-40125 range, 40131, 40135 or 40136. fn from_code(code: u32) -> Option { match code { 40120 => Some(Self::ReferencedEntityNotFound), @@ -73,6 +77,7 @@ impl DocumentReferenceErrorCodeWasm { 40125 => Some(Self::ReferencedKeyIdPropertyInvalid), 40131 => Some(Self::ReferencedDocumentTypeNotDeletable), 40135 => Some(Self::ReferencedContractRequirementNotMet), + 40136 => Some(Self::ReferencedIdentityKeyRequirementNotMet), _ => None, } } @@ -207,7 +212,7 @@ impl ConsensusErrorWasm { } /// The reference-validation error this is, or `undefined` when it is - /// not one of codes 40120-40125. + /// not one of codes 40120-40125, 40131, 40135 and 40136. #[wasm_bindgen(getter = "documentReferenceErrorCode")] pub fn document_reference_error_code(&self) -> Option { DocumentReferenceErrorCodeWasm::from_code(self.0.code()) @@ -245,6 +250,7 @@ mod tests { use dpp::consensus::state::document::referenced_entity_not_found_error::ReferencedEntityNotFoundError; use dpp::consensus::state::document::referenced_identity_key_disabled_error::ReferencedIdentityKeyDisabledError; use dpp::consensus::state::document::referenced_identity_key_not_found_error::ReferencedIdentityKeyNotFoundError; + use dpp::consensus::state::document::referenced_identity_key_requirement_not_met_error::ReferencedIdentityKeyRequirementNotMetError; use dpp::consensus::state::document::referenced_key_id_property_invalid_error::ReferencedKeyIdPropertyInvalidError; use dpp::consensus::state::state_error::StateError; use dpp::data_contract::document_type::DocumentPropertyReferenceTarget; @@ -381,6 +387,21 @@ mod tests { .into(), DocumentReferenceErrorCodeWasm::ReferencedContractRequirementNotMet, ), + ( + StateError::ReferencedIdentityKeyRequirementNotMetError( + ReferencedIdentityKeyRequirementNotMetError::new( + "joinRequest".to_string(), + "recipientId".to_string(), + id(), + 3, + "purpose".to_string(), + "decryption".to_string(), + "encryption".to_string(), + ), + ) + .into(), + DocumentReferenceErrorCodeWasm::ReferencedIdentityKeyRequirementNotMet, + ), ( StateError::ReferencedDocumentTypeNotFoundError( ReferencedDocumentTypeNotFoundError::new( @@ -478,7 +499,7 @@ mod tests { #[test] fn codes_outside_the_reference_range_are_not_claimed() { - for code in [40119, 40126, 0, 40200] { + for code in [40119, 40126, 40137, 0, 40200] { assert_eq!(DocumentReferenceErrorCodeWasm::from_code(code), None); } } diff --git a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs index 5315e8c8e4e..2539291597d 100644 --- a/packages/wasm-dpp2/src/data_contract/document_type_reference.rs +++ b/packages/wasm-dpp2/src/data_contract/document_type_reference.rs @@ -106,6 +106,27 @@ export type DocumentPropertyReferenceTarget = * identity id. A dotted path when the property is nested. */ keyIdProperty: string; + /** + * What the referenced key must be beyond existing and not being + * disabled, checked by consensus when the referring document is + * written against the key fetched for the existence check: + * `purpose` requires the key's purpose to be the named one, and + * `boundTo` requires the key's contract bounds to be exactly the + * declaring contract and the named document type of it; a + * whole-contract or contract group bound never meets it (code 40136 + * when either is unmet). Absent when the declaration carries no + * requirement. + */ + keyRequirements?: { + purpose?: + | 'authentication' + | 'encryption' + | 'decryption' + | 'transfer' + | 'voting' + | 'owner'; + boundTo?: string; + }; } | { /** @@ -142,7 +163,8 @@ export type DocumentPropertyReference = { * example `"author"`, or `"meta.parentId"` for a nested one. * * This is the same string consensus reports in the `path` field of the - * document-write reference errors (codes 40120-40125). Note that contract + * document-write reference errors (codes 40120-40125, 40131, 40135 and + * 40136). Note that contract * *registration* errors prefix it with the document type name * (`"."`) while document *write* errors do not. */ @@ -278,13 +300,38 @@ fn reference_to_js( set_field(&object, "propertyAgreement", &agreement, path)?; } } - DocumentPropertyReferenceTarget::IdentityPublicKey { key_id_property } => { + DocumentPropertyReferenceTarget::IdentityPublicKey { + key_id_property, + key_requirements, + } => { set_field( &object, "keyIdProperty", &JsValue::from_str(key_id_property), path, )?; + // Absent, not `{}`-valued, when the declaration requires nothing, + // matching the schema's own omission. + if !key_requirements.is_empty() { + let fields = Object::new(); + if let Some(purpose) = key_requirements.purpose { + set_field( + &fields, + "purpose", + &JsValue::from_str(purpose.wire_name()), + path, + )?; + } + if let Some(document_type_name) = &key_requirements.bound_to { + set_field( + &fields, + "boundTo", + &JsValue::from_str(document_type_name), + path, + )?; + } + set_field(&object, "keyRequirements", &fields, path)?; + } } } diff --git a/packages/wasm-dpp2/src/enums/keys/purpose.rs b/packages/wasm-dpp2/src/enums/keys/purpose.rs index 7a250a65bcf..bb00350a3ff 100644 --- a/packages/wasm-dpp2/src/enums/keys/purpose.rs +++ b/packages/wasm-dpp2/src/enums/keys/purpose.rs @@ -51,19 +51,15 @@ impl TryFrom<&JsValue> for PurposeWasm { fn try_from(value: &JsValue) -> Result { if let Some(enum_val) = value.as_string() { - return match enum_val.to_lowercase().as_str() { - "authentication" => Ok(PurposeWasm::AUTHENTICATION), - "encryption" => Ok(PurposeWasm::ENCRYPTION), - "decryption" => Ok(PurposeWasm::DECRYPTION), - "transfer" => Ok(PurposeWasm::TRANSFER), - "system" => Ok(PurposeWasm::SYSTEM), - "voting" => Ok(PurposeWasm::VOTING), - "owner" => Ok(PurposeWasm::OWNER), - _ => Err(WasmDppError::invalid_argument(format!( - "unsupported purpose value ({})", - enum_val - ))), - }; + // The names are the purpose's own wire names, as the schema keywords spell them + return Purpose::from_wire_name(&enum_val.to_lowercase()) + .map(PurposeWasm::from) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "unsupported purpose value ({})", + enum_val + )) + }); } if let Some(enum_val) = value.as_f64() { diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts index e63c0ef1018..5bdb32acdec 100644 --- a/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyReference.spec.ts @@ -48,6 +48,9 @@ const schemas = { // A `permanentDocument` target must not be deletable, and `note` // references itself below. canBeDeleted: false, + // `recipientKey` below requires a decryption key bound to `note`, which + // only a type taking bound decryption keys can be. + requiresIdentityDecryptionBoundedKey: 2, properties: { author: identifierProperty(0, { type: 'identity' }), sourceContract: identifierProperty(1, { type: 'contract' }), @@ -78,6 +81,14 @@ const schemas = { }, additionalProperties: false, }, + // `keyRequirements`: the referenced key must have this purpose and be + // bound to this contract's `note` type (PV14 #4918). + recipientKey: identifierProperty(8, { + type: 'identityPublicKey', + keyIdProperty: 'recipientKeyId', + keyRequirements: { purpose: 'decryption', boundTo: 'note' }, + }), + recipientKeyId: { type: 'integer', position: 9, minimum: 0 }, }, additionalProperties: false, }, @@ -107,6 +118,7 @@ type Reference = { contractId?: { toBase58(): string }; documentType?: string; keyIdProperty?: string; + keyRequirements?: { purpose?: string; boundTo?: string }; propertyAgreement?: Record; }; @@ -124,6 +136,7 @@ describe('DataContract — refersTo declarations (v14)', () => { 'otherDoc', 'signerKey', 'meta.ownerRef', + 'recipientKey', ]); }); @@ -139,6 +152,7 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(byPath.get('otherDoc')!.type).to.equal('permanentDocument'); expect(byPath.get('signerKey')!.type).to.equal('identityPublicKey'); expect(byPath.get('meta.ownerRef')!.type).to.equal('identity'); + expect(byPath.get('recipientKey')!.type).to.equal('identityPublicKey'); }); it('should carry no target fields for the bare kinds', () => { @@ -202,6 +216,22 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(signerKey.keyIdProperty).to.equal('signerKeyId'); }); + it('should carry keyRequirements when declared and omit them otherwise', () => { + const contract = buildContract(14); + const references = contract.documentTypeReferences('note') as Reference[]; + const recipientKey = references.find((reference) => reference.path === 'recipientKey')!; + const signerKey = references.find((reference) => reference.path === 'signerKey')!; + + expect(recipientKey).to.deep.equal({ + path: 'recipientKey', + type: 'identityPublicKey', + keyIdProperty: 'recipientKeyId', + keyRequirements: { purpose: 'decryption', boundTo: 'note' }, + }); + // Absent, not `{}`-valued, like the schema's own omission. + expect(signerKey).to.not.have.property('keyRequirements'); + }); + it('should return an empty array for a document type declaring none', () => { const contract = buildContract(14); @@ -275,6 +305,9 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(wasm.DocumentReferenceErrorCode.ReferencedIdentityKeyNotFound).to.equal(40123); expect(wasm.DocumentReferenceErrorCode.ReferencedIdentityKeyDisabled).to.equal(40124); expect(wasm.DocumentReferenceErrorCode.ReferencedKeyIdPropertyInvalid).to.equal(40125); + expect(wasm.DocumentReferenceErrorCode.ReferencedDocumentTypeNotDeletable).to.equal(40131); + expect(wasm.DocumentReferenceErrorCode.ReferencedContractRequirementNotMet).to.equal(40135); + expect(wasm.DocumentReferenceErrorCode.ReferencedIdentityKeyRequirementNotMet).to.equal(40136); }); it('should resolve a code back to its name', () => { @@ -282,6 +315,7 @@ describe('DataContract — refersTo declarations (v14)', () => { expect(codes[40123]).to.equal('ReferencedIdentityKeyNotFound'); expect(codes[40125]).to.equal('ReferencedKeyIdPropertyInvalid'); + expect(codes[40136]).to.equal('ReferencedIdentityKeyRequirementNotMet'); }); }); });