diff --git a/book/src/data-model/documents.md b/book/src/data-model/documents.md index d9dda2a3d10..804e8519871 100644 --- a/book/src/data-model/documents.md +++ b/book/src/data-model/documents.md @@ -364,6 +364,45 @@ A transfer or purchase changes `$ownerId` without touching the data, so the tran In Rust the declaration is `DocumentProperty::distinct_from` (`Option`, absent on every property parsed before protocol version 14), the document check is `DocumentTypeV0Methods::validate_distinct_from_properties`, and `DistinctFrom::violation` judges one value on its own, which is how the elements of a typed array are judged one by one. +## Encrypted Properties (`encryptedFor`) + +A byte array property may hold ciphertext that only one identity can read. Before protocol version 14 the contract said nothing about it, so every wallet had to learn the recipe (whose keys, which scheme, where the IV sits) from documentation or a side channel. From protocol version 14 the property declares it with the `encryptedFor` keyword, and wallets and SDKs read the recipe from the contract. + +```json +"encryptedMessage": { + "type": "array", "byteArray": true, "minItems": 32, "maxItems": 1040, + "encryptedFor": { + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }, + "position": 4 +} +``` + +All four keys are required. `recipient` is the dotted path of an identifier property of the same document type whose value is the recipient identity's id, or `$ownerId` for a message the writer encrypts to themself. `recipientKey` and `senderKey` are dotted paths of integer properties of the same document type carrying the recipient's and the sender's identity key ids; each must declare `minimum` at least 0 and `maximum` at most 4294967295, read from the schema itself, so the rule holds whatever `sizedIntegerTypes` the contract sets. `scheme` is a closed set with one member today. + +The parser (generation 3, meta-schema v3) admits the keyword on byte array properties only, never on an identifier (`contentMediaType` set) or any other type, and checks at contract registration that the three named properties exist with those types, that none of them is `transient` (a transient property is stripped before storage, which would leave the stored ciphertext without its recipe), and that the byte array's own `maxItems` can hold the scheme's shortest ciphertext. A contract update that adds, removes or changes an `encryptedFor` declaration is an incompatible schema change (`IncompatibleDocumentTypeSchemaError`, 10246): documents already written under the old recipe could not be read under the new one. Contracts parsed before protocol version 14 ignore the keyword entirely. + +### The `ecdh-secp256k1-aes256-cbc` layout + +This is the scheme the dashpay contact request already uses for `encryptedPublicKey` and `encryptedAccountLabel` (DIP-15), implemented in `packages/rs-platform-encryption`: + +1. The shared key is the libsecp256k1 ECDH of the sender's private key and the recipient's public key: `SHA256(parity || x)` of the product point, where `parity` is `0x02` for an even `y` and `0x03` for an odd one. The sender's key is the identity key with the id the `senderKey` property carries, on the document's `$ownerId` identity; the recipient's key is the one with the id the `recipientKey` property carries, on the identity the `recipient` property names (the owner itself for `$ownerId`). Either side derives the same 32 bytes from its own private key and the other's public key. +2. The writer draws a random 16-byte IV. +3. The value is the IV followed by the plaintext encrypted with AES-256-CBC under the shared key and that IV, with PKCS7 padding. + +So a ciphertext is `16 + 16 * ceil((len(plaintext) + 1) / 16)` bytes: at least 32, always a multiple of 16. The reader splits the first 16 bytes off as the IV, derives the shared key from its own private key and the sender's public key, and decrypts the rest. + +### What consensus checks, and what it cannot + +Consensus sees bytes, not keys. On every document create and replace, after the JSON schema validation of the document's properties, the structure validation (create structure generation 1, introduced at protocol version 14, and replace structure generation 0, extended in place: the call is inert before 14, where no property can carry the keyword) walks the document type's declared properties and, for each one the transition supplies, checks that its length is at least the scheme's IV plus one block and a multiple of the block length. A value that is not refuses the transition with `InvalidEncryptedPropertyShapeError` (basic code 10420), which names the property path, the scheme and the lengths involved. No state is read; the check runs in the mempool as well as in the block. The JSON schema's own `minItems` and `maxItems` are checked first, so a value outside them (a lone 16-byte IV against `minItems: 32`, say) is refused with the schema's error rather than 10420; the shape check only sees values the bounds already admit. + +Nothing else is verifiable on chain: not that the bytes decrypt, not that they decrypt under the keys the document names, not that the named key ids exist on the identities or have an encryption purpose, and not that the plaintext is what the document type means it to be. A writer can store any 32 bytes. Whether the keys exist and are of the right kind is what the reference keywords are for: a `refersTo` of type `identityPublicKey` with `keyIdProperty` on the recipient property makes consensus check that the recipient's key exists, and `encryptedFor` neither duplicates nor requires it. Readers must treat a value that fails to decrypt as a bad message, not as a protocol violation. + +In Rust the declaration is `DocumentProperty::encrypted_for` (`Option`), listed per document type by `DocumentTypeV0Getters::encrypted_properties()`, and the shape check is `DocumentTypeBasicMethods::validate_encrypted_property_shapes()`, versioned on the `validate_encrypted_property_shapes` method slot (`None` before protocol version 14, which is what keeps the in-place replace call inert). In JavaScript, `contract.documentTypeEncryptedProperties(name)` and `contract.documentEncryptedProperties` expose the same declarations, and the shape error reaches an app as `DocumentEncryptionErrorCode.InvalidEncryptedPropertyShape`. + ## Rules and Guidelines **Do:** diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index 8ac4cf33e7b..27e6c5f57c4 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -53,7 +53,7 @@ Error codes are organized into ranges that correspond to error categories and su | 10200-10277 | Data Contract | `DataContractMaxDepthExceedError` (10200), `DuplicateIndexError` (10201), `InvalidDataContractIdError` (10204), `DataContractInvalidRequiredFieldsUpdateError` (10276), `PreProgrammedDistributionAmountOverLimitError` (10277) | | 10350-10359 | Groups | `GroupPositionDoesNotExistError` (10350), `GroupExceedsMaxMembersError` (10354) | | 10360-10367 | Contract Groups | `ContractGroupMembershipsOverLimitError` (10360), `InvalidContractGroupAdminsError` (10364), `InvalidContractGroupDescriptionLengthError` (10367); 10365 unassigned | -| 10400-10419 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419) | +| 10400-10420 | Documents | `DataContractNotPresentError` (10400), `DuplicateDocumentTransitionsWithIdsError` (10401), `DocumentPropertyNotDistinctError` (10419), `InvalidEncryptedPropertyShapeError` (10420) | | 10450-10460 | Tokens | `InvalidTokenIdError` (10450), `TokenTransferToOurselfError` (10456) | | 10500-10535 | Identity | `DuplicatedIdentityPublicKeyBasicError` (10500), `InvalidIdentityPublicKeyDataError` (10511) | | 10600-10603 | State Transition | `InvalidStateTransitionTypeError` (10600), `StateTransitionMaxSizeExceededError` (10602) | diff --git a/packages/js-evo-sdk/README.md b/packages/js-evo-sdk/README.md index d8a36204c73..f7a7e25b3cf 100644 --- a/packages/js-evo-sdk/README.md +++ b/packages/js-evo-sdk/README.md @@ -241,6 +241,42 @@ const stateTransition = batch.toStateTransition(); From protocol version 14 the id of a new document commits to the identity contract nonce of its create transition. `new DocumentCreateTransition(...)` derives that id from the document's entropy and `identityContractNonce`, puts it on the transition and writes it back onto `document`, so `document.id` is final once the transition exists and equals `transition.base.id`. Before that the `Document` carries a placeholder. To know the id earlier, `document.setIdForCreation(nonce)` or `Document.generateId(type, owner, contract, entropy, nonce)`, or pass `identityContractNonce` to the `Document` constructor. Pass `platformVersion` (defaults to latest) to any of them for a network on an earlier protocol version. No app needs to reimplement the hash. +## Encrypted properties (`encryptedFor`) + +From protocol version 14 a byte array property can declare how its ciphertext was produced, so a wallet reads the recipe from the contract instead of a side channel: the recipient (an identifier property of the same document type, or `$ownerId` for a message the writer encrypts to themself), the integer properties carrying the recipient's and the sender's key ids, and the scheme. The one scheme today, `ecdh-secp256k1-aes256-cbc`, is the dashpay contact request's: a random 16-byte IV followed by AES-256-CBC with PKCS7 padding under the libsecp256k1 ECDH shared key of the two identities' keys. A fetched contract can be asked what it declares: + +```ts +const contract = await sdk.contracts.fetch(contractId); + +contract.documentTypeEncryptedProperties('joinRequest'); +// [{ +// path: 'encryptedMessage', +// recipient: 'recipientId', +// recipientKey: 'recipientKeyId', +// senderKey: 'senderKeyId', +// scheme: 'ecdh-secp256k1-aes256-cbc', +// }] + +// Every document type that declares at least one encrypted property. +contract.documentEncryptedProperties; +``` + +The keyword is only parsed from protocol version 14 onward; a contract deserialized against an earlier version reports none even when its raw schema carries it. Consensus checks only the shape of the bytes on every create and replace (at least 32 bytes and a multiple of 16 for AES-CBC) and nothing about who can decrypt them. A value of the wrong shape is rejected, and the code reaches JS as `error.code`: + +```ts +import { DocumentEncryptionErrorCode } from '@dashevo/evo-sdk'; + +try { + await sdk.documents.create({ document, identityKey, signer }); +} catch (e) { + if (e.code === DocumentEncryptionErrorCode.InvalidEncryptedPropertyShape) { + // the bytes are not a ciphertext of the declared scheme (code 10420) + } +} +``` + +Encrypt and decrypt helpers keyed off the declaration are not part of the SDK yet; the Rust `platform-encryption` crate has the primitives. + ## Immutable properties (`immutable`) From protocol version 14 a mutable document type can freeze some of its top-level properties at creation with the doctype-level `immutable` list, while the rest of the document stays replaceable. A second list, `immutableAllowSetting`, names the frozen properties a replace may still set while the stored document has no value for them; once present they are frozen too. Both are consensus-enforced on every replace, and a fetched contract can be asked what it declares: 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 3bc68d67601..08d28a2dcbb 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 @@ -95,6 +95,39 @@ "description": "typed arrays only: the schema every element of the array has", "$ref": "#/$defs/documentArrayItem" }, + "encryptedFor": { + "description": "byte array properties that are not identifiers only: how the property's ciphertext was produced, so wallets and SDKs read the recipe from the contract. recipient names an identifier property of the same document type whose value is the recipient identity's id, or is \"$ownerId\" for a message the writer encrypts to themself; recipientKey and senderKey name integer properties of the same document type (minimum at least 0, maximum at most 4294967295) carrying the recipient's and the sender's identity key ids; scheme names how the bytes were made. Under ecdh-secp256k1-aes256-cbc, the scheme of the dashpay contact request, the shared key is the libsecp256k1 ECDH of the sender's private key and the recipient's public key (SHA256 of the product point's parity byte and x coordinate) and the value is a random 16-byte IV followed by the plaintext under AES-256-CBC with PKCS7 padding and that IV, so at least 32 bytes and a multiple of 16. All four keys are required and the three named properties must exist with those types. Consensus checks only that shape on every create and replace (InvalidEncryptedPropertyShapeError, 10420): who can decrypt the bytes, and whether they decrypt at all, is not verifiable on chain", + "type": "object", + "properties": { + "recipient": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "recipientKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "senderKey": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "scheme": { + "enum": [ + "ecdh-secp256k1-aes256-cbc" + ] + } + }, + "required": [ + "recipient", + "recipientKey", + "senderKey", + "scheme" + ], + "additionalProperties": false + }, "refersTo": { "type": "object", "properties": { @@ -445,6 +478,22 @@ "maxItems" ] }, + "encryptedFor": { + "description": "encryptedFor is only allowed on byte array properties that are not identifiers", + "properties": { + "type": { + "const": "array" + }, + "byteArray": { + "const": true + }, + "contentMediaType": false + }, + "required": [ + "type", + "byteArray" + ] + }, "format": { "description": "prevent slow format validation of large strings", "properties": { diff --git a/packages/rs-dpp/src/data_contract/document_type/accessors/v0/mod.rs b/packages/rs-dpp/src/data_contract/document_type/accessors/v0/mod.rs index 7c8b9aca0a9..ee3b2c48c4c 100644 --- a/packages/rs-dpp/src/data_contract/document_type/accessors/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/accessors/v0/mod.rs @@ -1,6 +1,6 @@ use crate::data_contract::document_type::index::Index; use crate::data_contract::document_type::index_level::IndexLevel; -use crate::data_contract::document_type::property::DocumentProperty; +use crate::data_contract::document_type::property::{DocumentProperty, EncryptedFor}; use platform_value::{Identifier, Value}; @@ -42,6 +42,23 @@ pub trait DocumentTypeV0Getters { /// Returns the properties of the document type. fn properties(&self) -> &IndexMap; + /// The properties declared `encryptedFor`, each by its dotted path in + /// schema order, with the declaration that says for whom, under which + /// keys and under which scheme their bytes were encrypted. Empty on a + /// document type declaring none, and on every contract parsed before + /// protocol version 14, which ignores the keyword. + fn encrypted_properties(&self) -> Vec<(&String, &EncryptedFor)> { + self.flattened_properties() + .iter() + .filter_map(|(path, property)| { + property + .encrypted_for + .as_ref() + .map(|encrypted_for| (path, encrypted_for)) + }) + .collect() + } + /// Returns the identifier paths of the document type. fn identifier_paths(&self) -> &BTreeSet; 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 3002f4b2618..d356c60ecf2 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 @@ -3,11 +3,13 @@ use crate::data_contract::document_type::class_methods::apply_required_since::ap use crate::data_contract::document_type::class_methods::parse_typed_array::parse_typed_array; use crate::data_contract::document_type::v0::DocumentTypeV0; use crate::data_contract::document_type::v1::DocumentTypeV1; +use crate::data_contract::document_type::v2::DocumentTypeV2; use crate::data_contract::document_type::{ is_referenced_system_agreement_property, is_referring_system_agreement_property, property_names, ContractReferenceModeration, ContractReferenceOwner, ContractReferenceRequirements, DistinctFrom, DocumentProperty, DocumentPropertyReferenceTarget, - DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentType, + DocumentPropertyType, DocumentPropertyTypeParsingOptions, DocumentType, EncryptedFor, + EncryptedForRecipient, EncryptionScheme, }; use crate::data_contract::errors::DataContractError; use crate::data_contract::{TokenConfiguration, TokenContractPosition}; @@ -28,6 +30,11 @@ mod v3; const NOT_ALLOWED_SYSTEM_PROPERTIES: [&str; 1] = ["$id"]; +/// The longest property path a keyword may name: `keyIdProperty`, the +/// `propertyAgreement` pairs and the `encryptedFor` paths share it, and the +/// meta-schema states the same bound as `maxLength`. +const MAX_PROPERTY_PATH_LENGTH: usize = 256; + const MAX_INDEXED_STRING_PROPERTY_LENGTH: u16 = 63; const MAX_INDEXED_BYTE_ARRAY_PROPERTY_LENGTH: u16 = 255; const MAX_INDEXED_ARRAY_ITEMS: usize = 1024; @@ -193,6 +200,8 @@ fn insert_values( apply_property_reference(&inner_properties, property_type, platform_version)?; let distinct_from = apply_distinct_from(&inner_properties, &property_type, platform_version)?; + let encrypted_for = + apply_encrypted_for(&inner_properties, &property_type, platform_version)?; document_properties.insert( prefixed_property_key, DocumentProperty { @@ -201,6 +210,7 @@ fn insert_values( transient: is_transient, required_since, distinct_from, + encrypted_for, }, ); } @@ -323,6 +333,7 @@ fn insert_values_nested( let property_type = apply_property_reference(&inner_properties, property_type, platform_version)?; let distinct_from = apply_distinct_from(&inner_properties, &property_type, platform_version)?; + let encrypted_for = apply_encrypted_for(&inner_properties, &property_type, platform_version)?; document_properties.insert( property_key, @@ -332,6 +343,7 @@ fn insert_values_nested( transient: is_transient, required_since, distinct_from, + encrypted_for, }, ); @@ -626,11 +638,12 @@ fn apply_property_reference_v0( ) })?; for path in [referring_property.as_str(), referenced_property] { - if path.is_empty() || path.len() > 256 { + if path.is_empty() || path.len() > MAX_PROPERTY_PATH_LENGTH { return Err(DataContractError::InvalidContractStructure( - "propertyAgreement property paths must be between 1 \ - and 256 characters" - .to_string(), + format!( + "propertyAgreement property paths must be between 1 \ + and {MAX_PROPERTY_PATH_LENGTH} characters" + ), )); } } @@ -684,11 +697,11 @@ fn apply_property_reference_v0( .get_str(property_names::KEY_ID_PROPERTY) .map_err(|e| DataContractError::ValueWrongType(e.to_string()))?; - if key_id_property.is_empty() || key_id_property.len() > 256 { - return Err(DataContractError::InvalidContractStructure( - "identityPublicKey refersTo keyIdProperty must be between 1 and 256 characters" - .to_string(), - )); + if key_id_property.is_empty() || key_id_property.len() > MAX_PROPERTY_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "identityPublicKey refersTo keyIdProperty must be between 1 and \ + {MAX_PROPERTY_PATH_LENGTH} characters" + ))); } DocumentPropertyReferenceTarget::IdentityPublicKey { @@ -717,6 +730,281 @@ fn apply_property_reference_v0( Ok(DocumentPropertyType::IdentifierWithReference(target)) } +/// Reads a property's `encryptedFor` declaration: how the bytes of a byte +/// array property were encrypted. Non-byte-array properties, identifiers +/// among them, cannot carry it. +/// +/// Versioned on `apply_encrypted_for` in the platform version's document type +/// schema versions. `None` selects the behavior of the versions that predate +/// the keyword: it is ignored entirely, so their parses stay byte-for-byte +/// identical to what they always produced. +fn apply_encrypted_for( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, + platform_version: &PlatformVersion, +) -> Result, DataContractError> { + match platform_version + .dpp + .contract_versions + .document_type_versions + .schema + .apply_encrypted_for + { + None => Ok(None), + Some(0) => apply_encrypted_for_v0(inner_properties, property_type), + Some(version) => Err(DataContractError::Unsupported(format!( + "apply_encrypted_for version {version} is not supported" + ))), + } +} + +fn apply_encrypted_for_v0( + inner_properties: &BTreeMap, + property_type: &DocumentPropertyType, +) -> Result, DataContractError> { + let Some(encrypted_for_value) = inner_properties.get(property_names::ENCRYPTED_FOR) else { + return Ok(None); + }; + + match property_type { + DocumentPropertyType::ByteArray(_) => {} + DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_) => { + return Err(DataContractError::InvalidContractStructure( + "encryptedFor is not allowed on identifier properties, only on byte arrays" + .to_string(), + )); + } + _ => { + return Err(DataContractError::InvalidContractStructure( + "encryptedFor is only allowed on byte array properties".to_string(), + )); + } + } + + let encrypted_for_map = encrypted_for_value.to_btree_ref_string_map()?; + + for key in encrypted_for_map.keys() { + if !matches!( + key.as_str(), + property_names::RECIPIENT + | property_names::RECIPIENT_KEY + | property_names::SENDER_KEY + | property_names::SCHEME + ) { + return Err(DataContractError::InvalidContractStructure(format!( + "encryptedFor {key:?} is unknown, expected recipient, recipientKey, senderKey \ + and scheme" + ))); + } + } + + let path = |key: &'static str| -> Result<&str, DataContractError> { + let Some(value) = encrypted_for_map.get(key) else { + return Err(DataContractError::InvalidContractStructure(format!( + "encryptedFor must declare {key}" + ))); + }; + let path = value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "encryptedFor {key} must be a property path (a string)" + )) + })?; + if path.is_empty() || path.len() > MAX_PROPERTY_PATH_LENGTH { + return Err(DataContractError::InvalidContractStructure(format!( + "encryptedFor {key} must be between 1 and {MAX_PROPERTY_PATH_LENGTH} characters" + ))); + } + Ok(path) + }; + + let recipient = EncryptedForRecipient::from_path(path(property_names::RECIPIENT)?); + if recipient + .property_path() + .is_some_and(|recipient_path| recipient_path.starts_with('$')) + { + return Err(DataContractError::InvalidContractStructure( + "encryptedFor recipient must name an identifier property of the document type or \ + its $ownerId" + .to_string(), + )); + } + + let mut key_paths = [String::new(), String::new()]; + for (key, slot) in [property_names::RECIPIENT_KEY, property_names::SENDER_KEY] + .into_iter() + .zip(key_paths.iter_mut()) + { + let key_path = path(key)?; + if key_path.starts_with('$') { + return Err(DataContractError::InvalidContractStructure(format!( + "encryptedFor {key} must name an integer property of the document type, not a \ + system property" + ))); + } + *slot = key_path.to_string(); + } + let [recipient_key, sender_key] = key_paths; + + let scheme_value = encrypted_for_map + .get(property_names::SCHEME) + .ok_or_else(|| { + DataContractError::InvalidContractStructure( + "encryptedFor must declare scheme".to_string(), + ) + })?; + let scheme_name = scheme_value.as_text().ok_or_else(|| { + DataContractError::InvalidContractStructure( + "encryptedFor scheme must be a string".to_string(), + ) + })?; + let scheme = EncryptionScheme::from_wire_name(scheme_name).ok_or_else(|| { + DataContractError::InvalidContractStructure(format!( + "encryptedFor scheme {scheme_name:?} is unknown, expected one of {}", + EncryptionScheme::ALL + .iter() + .map(|scheme| format!("{:?}", scheme.as_str())) + .collect::>() + .join(", ") + )) + })?; + + Ok(Some(EncryptedFor { + recipient, + recipient_key, + sender_key, + scheme, + })) +} + +/// Checks every `encryptedFor` declaration of a document type against the +/// properties it names, once all of them are parsed: the recipient must be an +/// identifier property (or `$ownerId`), the two key properties integers whose +/// schema declares `minimum` at least 0 and `maximum` at most 4294967295 (read +/// from the schema itself, so the rule holds whatever `sizedIntegerTypes` the +/// contract sets), none of them may be `transient` (a transient property is +/// stripped before storage, which would leave the stored ciphertext without +/// its recipe), and the byte array's own `maxItems` must hold the scheme's +/// shortest ciphertext. Paths are looked up among the flattened properties, +/// so a nested property is named by its dotted path. +/// +/// Owned by parser generation 3: the only generation that admits the keyword. +pub(super) fn validate_encrypted_for_declarations( + document_type: &DocumentTypeV2, + document_type_name: &str, +) -> Result<(), DataContractError> { + let flattened_properties = &document_type.flattened_properties; + for (path, property) in flattened_properties { + let Some(encrypted_for) = &property.encrypted_for else { + continue; + }; + let structure_error = |message: String| { + DataContractError::InvalidContractStructure(format!( + "document type \"{document_type_name}\" property \"{path}\" encryptedFor {message}" + )) + }; + + let shortest = encrypted_for.scheme.minimum_ciphertext_length(); + if let DocumentPropertyType::ByteArray(sizes) = &property.property_type { + if let Some(max_size) = sizes.max_size.filter(|max| usize::from(*max) < shortest) { + return Err(structure_error(format!( + "maxItems {max_size} is below the {shortest} bytes the {} scheme produces \ + at least, so no document could ever carry it", + encrypted_for.scheme + ))); + } + } + + if let Some(recipient_path) = encrypted_for.recipient.property_path() { + match flattened_properties + .get(recipient_path) + .map(|recipient| &recipient.property_type) + { + Some( + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_), + ) => {} + Some(other) => { + return Err(structure_error(format!( + "recipient \"{recipient_path}\" has type {}, not identifier", + other.name() + ))); + } + None => { + return Err(structure_error(format!( + "recipient \"{recipient_path}\" is not a property of the document type" + ))); + } + } + if document_type.transient_fields.contains(recipient_path) { + return Err(structure_error(format!( + "recipient \"{recipient_path}\" is transient: a transient property is never \ + stored, so a reader could not tell whom the bytes are for" + ))); + } + } + + for (key, key_path) in [ + (property_names::RECIPIENT_KEY, &encrypted_for.recipient_key), + (property_names::SENDER_KEY, &encrypted_for.sender_key), + ] { + if !flattened_properties.contains_key(key_path) { + return Err(structure_error(format!( + "{key} \"{key_path}\" is not a property of the document type" + ))); + } + if !is_key_id_schema(&document_type.schema, key_path)? { + return Err(structure_error(format!( + "{key} \"{key_path}\" must be an integer property with minimum at least 0 \ + and maximum at most {}, so that it carries a key id", + u32::MAX + ))); + } + if document_type.transient_fields.contains(key_path) { + return Err(structure_error(format!( + "{key} \"{key_path}\" is transient: a transient property is never stored, \ + so a reader could not tell which key decrypts the bytes" + ))); + } + } + } + Ok(()) +} + +/// Whether the property at the dotted `path` of `schema` is declared as an +/// integer with `minimum` at least 0 and `maximum` at most `u32::MAX`, read +/// from the schema rather than from the parsed type so that the answer does +/// not depend on the contract's `sizedIntegerTypes`. `$ref`s are followed. +fn is_key_id_schema(schema: &Value, path: &str) -> Result { + fn resolve<'a>( + root_schema: &'a Value, + value: &'a Value, + ) -> Result, DataContractError> { + let map = value.to_btree_ref_string_map()?; + match map.get_optional_str(property_names::REF)? { + Some(schema_ref) => { + Ok(resolve_uri(root_schema, schema_ref)?.to_btree_ref_string_map()?) + } + None => Ok(map), + } + } + let mut current = resolve(schema, schema)?; + for segment in path.split('.') { + let Some(properties) = current.get(property_names::PROPERTIES) else { + return Ok(false); + }; + let Some(next) = properties.to_btree_ref_string_map()?.get(segment).copied() else { + return Ok(false); + }; + current = resolve(schema, next)?; + } + let is_integer = current.get_optional_str(property_names::TYPE)? == Some("integer"); + let minimum = current.get_optional_integer::(property_names::MINIMUM)?; + let maximum = current.get_optional_integer::(property_names::MAXIMUM)?; + Ok(is_integer + && minimum.is_some_and(|minimum| minimum >= 0) + && maximum.is_some_and(|maximum| maximum <= i64::from(u32::MAX))) +} + /// The `contractRequirements` of a `contract` reference: each key an aspect of the referenced /// contract with a closed set of values (`moderation`, `owner`, the config flags), or a bound /// on it (`minimumAgeSeconds`), at least one when the object is given at all. @@ -2449,6 +2737,357 @@ mod tests { } } + // ================================================================ + // encryptedFor + // ================================================================ + + /// The `encryptedFor` declaration every encryption test starts from. + fn encrypted_for_declaration() -> serde_json::Value { + json!({ + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }) + } + + /// A document type with a recipient identifier, two bounded key ids, an + /// unbounded integer, a string and a nested object next to the + /// `encryptedMessage` byte array carrying `encrypted_for`. + fn encrypted_schema(encrypted_for: 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 + }, + "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 1 }, + "senderKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 2 }, + "unboundedKeyId": { "type": "integer", "minimum": 0, "position": 3 }, + "note": { "type": "string", "maxLength": 63, "position": 4 }, + "maxOnlyKeyId": { "type": "integer", "maximum": 100, "position": 7 }, + "meta": { + "type": "object", + "position": 5, + "properties": { + "authorId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + } + }, + "additionalProperties": false + }, + "encryptedMessage": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 1040, + "position": 6, + "encryptedFor": encrypted_for + } + }, + "required": [], + "additionalProperties": false + }) + } + + fn encrypted_for_of(document_type: &DocumentType, path: &str) -> Option { + document_type + .as_ref() + .flattened_properties() + .get(path) + .expect("property should be present") + .encrypted_for + .clone() + } + + #[test] + fn should_parse_encrypted_for_on_a_byte_array_property() { + let document_type = try_document_type_from_schema_full_validation(encrypted_schema( + encrypted_for_declaration(), + )) + .expect("should parse"); + + let expected = EncryptedFor { + recipient: EncryptedForRecipient::Property("recipientId".to_string()), + recipient_key: "recipientKeyId".to_string(), + sender_key: "senderKeyId".to_string(), + scheme: EncryptionScheme::EcdhSecp256k1Aes256Cbc, + }; + assert_eq!( + encrypted_for_of(&document_type, "encryptedMessage"), + Some(expected.clone()) + ); + assert_eq!( + document_type.as_ref().encrypted_properties(), + vec![(&"encryptedMessage".to_string(), &expected)] + ); + assert_eq!(encrypted_for_of(&document_type, "note"), None); + } + + #[test] + fn should_parse_encrypted_for_with_the_owner_and_a_nested_identifier_as_recipient() { + for (recipient, expected) in [ + ("$ownerId", EncryptedForRecipient::Owner), + ( + "meta.authorId", + EncryptedForRecipient::Property("meta.authorId".to_string()), + ), + ] { + let mut declaration = encrypted_for_declaration(); + declaration["recipient"] = json!(recipient); + let document_type = + try_document_type_from_schema(encrypted_schema(declaration)).expect("should parse"); + assert_eq!( + encrypted_for_of(&document_type, "encryptedMessage") + .expect("should be declared") + .recipient, + expected + ); + } + } + + #[test] + fn should_reject_encrypted_for_on_a_non_byte_array_or_identifier_property() { + let mut on_string = encrypted_schema(encrypted_for_declaration()); + on_string["properties"]["note"]["encryptedFor"] = encrypted_for_declaration(); + on_string["properties"]["encryptedMessage"] + .as_object_mut() + .expect("object") + .remove("encryptedFor"); + let err = try_document_type_from_schema(on_string).expect_err("should be refused"); + assert!( + err.to_string() + .contains("encryptedFor is only allowed on byte array properties"), + "{err}" + ); + + let mut on_identifier = encrypted_schema(encrypted_for_declaration()); + on_identifier["properties"]["recipientId"]["encryptedFor"] = encrypted_for_declaration(); + on_identifier["properties"]["encryptedMessage"] + .as_object_mut() + .expect("object") + .remove("encryptedFor"); + let err = + try_document_type_from_schema(on_identifier.clone()).expect_err("should be refused"); + assert!( + err.to_string() + .contains("encryptedFor is not allowed on identifier properties"), + "{err}" + ); + // The meta-schema refuses it on an identifier too, before the parser sees it + try_document_type_from_schema_full_validation(on_identifier) + .expect_err("the meta-schema should refuse encryptedFor on an identifier"); + } + + #[test] + fn should_reject_encrypted_for_with_a_missing_unknown_or_malformed_key_or_an_unknown_scheme() { + for (mutate, fragment) in [ + ( + Box::new(|d: &mut serde_json::Value| { + d.as_object_mut().expect("object").remove("recipient"); + }) as Box, + "must declare recipient", + ), + ( + Box::new(|d: &mut serde_json::Value| { + d.as_object_mut().expect("object").remove("scheme"); + }), + "must declare scheme", + ), + ( + Box::new(|d: &mut serde_json::Value| d["scheme"] = json!("rsa-oaep")), + "scheme \"rsa-oaep\" is unknown", + ), + ( + Box::new(|d: &mut serde_json::Value| d["iv"] = json!("ivProperty")), + "\"iv\" is unknown", + ), + ( + Box::new(|d: &mut serde_json::Value| d["recipient"] = json!(7)), + "recipient must be a property path", + ), + ( + Box::new(|d: &mut serde_json::Value| d["recipientKey"] = json!("")), + "recipientKey must be between 1 and 256 characters", + ), + ( + Box::new(|d: &mut serde_json::Value| d["recipient"] = json!("$createdAt")), + "recipient must name an identifier property", + ), + ( + Box::new(|d: &mut serde_json::Value| d["senderKey"] = json!("$ownerId")), + "senderKey must name an integer property", + ), + ] { + let mut declaration = encrypted_for_declaration(); + mutate(&mut declaration); + let err = try_document_type_from_schema(encrypted_schema(declaration.clone())) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment), + "{declaration}: expected {fragment:?}, got {err}" + ); + } + } + + #[test] + fn should_reject_encrypted_for_naming_a_property_that_is_missing_or_of_the_wrong_type() { + for (key, path, fragment) in [ + ("recipient", "note", "has type string, not identifier"), + ( + "recipient", + "nowhere", + "is not a property of the document type", + ), + ( + "recipientKey", + "note", + "must be an integer property with minimum at least 0", + ), + ( + "recipientKey", + "unboundedKeyId", + "must be an integer property with minimum at least 0", + ), + ( + "recipientKey", + "maxOnlyKeyId", + "must be an integer property with minimum at least 0", + ), + ( + "senderKey", + "recipientId", + "must be an integer property with minimum at least 0", + ), + ( + "senderKey", + "nowhere", + "is not a property of the document type", + ), + ] { + let mut declaration = encrypted_for_declaration(); + declaration[key] = json!(path); + let err = try_document_type_from_schema(encrypted_schema(declaration)) + .expect_err("should be refused"); + assert!( + err.to_string().contains(fragment), + "{key}={path}: expected {fragment:?}, got {err}" + ); + } + } + + #[test] + fn should_refuse_encrypted_for_below_platform_version_14_and_accept_it_at_14() { + let schema = encrypted_schema(encrypted_for_declaration()); + let v13 = PlatformVersion::get(13).expect("platform version 13 should exist"); + + // The v2 meta-schema does not know the keyword: a validating parse refuses it + let config = DataContractConfig::default_for_version(v13).expect("config should build"); + let value = platform_value::to_value(schema.clone()).expect("schema should convert"); + DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + value, + None, + &BTreeMap::new(), + &config, + true, + &mut vec![], + v13, + ) + .expect_err("platform version 13 should refuse encryptedFor under full validation"); + + // Without validation the tables carry `apply_encrypted_for: None`, so the keyword + // is ignored and the property parses as the plain byte array it always was + let document_type = + try_document_type_from_schema_on_version(schema.clone(), v13).expect("should parse"); + assert_eq!(encrypted_for_of(&document_type, "encryptedMessage"), None); + assert!(document_type.as_ref().encrypted_properties().is_empty()); + + // At 14 both parses carry it + let document_type = try_document_type_from_schema_full_validation(schema.clone()) + .expect("should parse at platform version 14"); + assert!(encrypted_for_of(&document_type, "encryptedMessage").is_some()); + let document_type = try_document_type_from_schema(schema).expect("should parse"); + assert!(encrypted_for_of(&document_type, "encryptedMessage").is_some()); + } + + #[test] + fn should_reject_encrypted_for_naming_a_transient_recipient_or_key() { + for (transient, fragment) in [ + ("recipientId", "recipient \"recipientId\" is transient"), + ("senderKeyId", "senderKey \"senderKeyId\" is transient"), + ] { + let mut schema = encrypted_schema(encrypted_for_declaration()); + schema["transient"] = json!([transient]); + let err = try_document_type_from_schema(schema).expect_err("should be refused"); + assert!(err.to_string().contains(fragment), "{transient}: got {err}"); + } + } + + #[test] + fn should_reject_encrypted_for_on_a_byte_array_too_short_for_the_scheme() { + let mut schema = encrypted_schema(encrypted_for_declaration()); + schema["properties"]["encryptedMessage"]["minItems"] = json!(1); + schema["properties"]["encryptedMessage"]["maxItems"] = json!(24); + let err = try_document_type_from_schema(schema).expect_err("should be refused"); + assert!( + err.to_string() + .contains("maxItems 24 is below the 32 bytes the ecdh-secp256k1-aes256-cbc"), + "{err}" + ); + } + + /// The key-id bounds are read from the schema, so a contract whose + /// integers are not sized (config V0, or `sizedIntegerTypes: false`) + /// can still declare the keyword. + #[test] + fn should_accept_encrypted_for_key_ids_when_sized_integer_types_are_off() { + use crate::data_contract::config::v0::DataContractConfigV0; + + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::V0(DataContractConfigV0::default()); + let value = platform_value::to_value(encrypted_schema(encrypted_for_declaration())) + .expect("schema should convert"); + + let document_type = DocumentType::try_from_schema( + Identifier::random(), + 0, + config.version(), + "msg", + value, + None, + &BTreeMap::new(), + &config, + false, + &mut vec![], + platform_version, + ) + .expect("should parse with unsized integers"); + + assert!(matches!( + document_type + .as_ref() + .flattened_properties() + .get("recipientKeyId") + .map(|p| &p.property_type), + Some(DocumentPropertyType::I64) + )); + assert!(encrypted_for_of(&document_type, "encryptedMessage").is_some()); + } + // ================================================================ // requiredSince // ================================================================ diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index 4e90059f77d..1e96e4636a9 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -40,6 +40,7 @@ use std::collections::BTreeMap; use crate::consensus::basic::data_contract::InvalidIndexedPropertyConstraintError; use super::common; +use super::validate_encrypted_for_declarations; mod ranked_prefix_overlap; use ranked_prefix_overlap::validate_no_ranked_prefix_overlap; @@ -391,6 +392,12 @@ fn try_from_schema_generation_3( full_validation, )?; + // After the core parse: every property, its transient flag and its schema + // are known, so each `encryptedFor` declaration can be checked against the + // properties it names. Generation 3 is the only one admitting the keyword. + validate_encrypted_for_declarations(&v2, name) + .map_err(consensus_or_protocol_data_contract_error)?; + // After `apply_index_only`: the flag is refused on an indexOnly type, so it // has to see that one already applied. common::apply_can_be_deleted_by_moderators( diff --git a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs index ab025b6925e..3186f70dcc8 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/preallocation.rs @@ -187,6 +187,7 @@ mod tests { required: true, required_since: None, distinct_from: None, + encrypted_for: None, transient: false, } } @@ -201,6 +202,7 @@ mod tests { required: true, required_since: None, distinct_from: None, + encrypted_for: None, transient: false, } } @@ -211,6 +213,7 @@ mod tests { required: true, required_since: None, distinct_from: None, + encrypted_for: None, transient: false, } } diff --git a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs index 1a95b2468c7..3ceb6d8eace 100644 --- a/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/methods/mod.rs @@ -13,12 +13,16 @@ use crate::validation::SimpleConsensusValidationResult; use crate::version::PlatformVersion; use crate::ProtocolError; +#[cfg(feature = "validation")] +use crate::consensus::basic::document::InvalidEncryptedPropertyShapeError; use crate::data_contract::document_type::accessors::{ DocumentTypeV0Getters, DocumentTypeV2Getters, }; use crate::data_contract::document_type::methods::versioned_methods::DocumentTypeV0MethodsVersioned; use crate::fee::Credits; use crate::voting::vote_polls::VotePoll; +#[cfg(feature = "validation")] +use platform_value::btreemap_extensions::BTreeValueMapPathHelper; use platform_value::{Identifier, Value}; pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { @@ -50,6 +54,85 @@ pub trait DocumentTypeBasicMethods: DocumentTypeV0Getters { || self.trade_mode().seller_sets_price() } + /// Checks the shape of every `encryptedFor` property `properties` supplies + /// against the scheme its declaration names: at least the IV plus one + /// block, and a multiple of the block length. Nothing else about a + /// ciphertext is verifiable on chain. A declared property the document + /// leaves out is not checked; whether it may be left out is the schema's + /// `required` list's business. + /// + /// Meant to run after the JSON schema validation of `properties`, which + /// already established that every supplied value is a byte array. A value + /// that still is not one is reported as a zero-length ciphertext rather + /// than skipped, so the two nodes can never disagree on it. + /// + /// Versioned on `validate_encrypted_property_shapes` in the document type + /// method versions: `None` before protocol version 14 returns an empty + /// result, which keeps the shipped structure validations that call it inert. + #[cfg(feature = "validation")] + fn validate_encrypted_property_shapes( + &self, + properties: &BTreeMap, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .dpp + .contract_versions + .document_type_versions + .methods + .validate_encrypted_property_shapes + { + None => Ok(SimpleConsensusValidationResult::default()), + Some(0) => Ok(self.validate_encrypted_property_shapes_v0(properties)), + Some(version) => Err(ProtocolError::UnknownVersionMismatch { + method: "validate_encrypted_property_shapes".to_string(), + known_versions: vec![0], + received: version, + }), + } + } + + #[cfg(feature = "validation")] + fn validate_encrypted_property_shapes_v0( + &self, + properties: &BTreeMap, + ) -> SimpleConsensusValidationResult { + let declared = self + .flattened_properties() + .iter() + .filter_map(|(path, property)| Some((path, property.encrypted_for.as_ref()?))); + for (path, encrypted_for) in declared { + let Ok(Some(value)) = properties.get_optional_at_path(path) else { + continue; + }; + let length = match value { + Value::Bytes(bytes) => bytes.len(), + Value::Bytes20(_) => 20, + Value::Bytes32(_) | Value::Identifier(_) => 32, + Value::Bytes36(_) => 36, + Value::Array(items) => items.len(), + other => other + .to_binary_bytes() + .map(|bytes| bytes.len()) + .unwrap_or(0), + }; + let scheme = encrypted_for.scheme; + if !scheme.is_valid_ciphertext_length(length) { + return SimpleConsensusValidationResult::new_with_error( + InvalidEncryptedPropertyShapeError::new( + path.clone(), + scheme.as_str().to_string(), + u32::try_from(length).unwrap_or(u32::MAX), + scheme.minimum_ciphertext_length() as u32, + scheme.block_length() as u32, + ) + .into(), + ); + } + } + SimpleConsensusValidationResult::new() + } + fn top_level_indices(&self) -> Vec<&IndexProperty> { self.indexes() .values() 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 2533d39f571..41c70011216 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 @@ -2162,6 +2162,137 @@ mod tests { } } + mod encrypted_for { + use super::*; + use crate::consensus::basic::BasicError; + use crate::data_contract::config::DataContractConfig; + use platform_value::platform_value; + use std::collections::BTreeMap; + + /// A document type with a recipient, two key ids and an + /// `encryptedMessage` carrying `encrypted_for` when given. + fn encrypted_document_type( + encrypted_for: Option, + platform_version: &PlatformVersion, + ) -> DocumentType { + let mut encrypted_message = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 1040, + "position": 3 + }); + if let Some(encrypted_for) = encrypted_for { + encrypted_message + .insert("encryptedFor".to_string(), encrypted_for) + .expect("should insert encryptedFor"); + } + + let schema = platform_value!({ + "type": "object", + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 1 }, + "senderKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 2 }, + "encryptedMessage": encrypted_message + }, + "signatureSecurityLevelRequirement": 0, + "additionalProperties": false, + }); + + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create a default config"); + + DocumentType::try_from_schema( + Identifier::random(), + 1, + config.version(), + "test", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("failed to create document type") + } + + fn declaration(recipient: &str) -> platform_value::Value { + platform_value!({ + "recipient": recipient, + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }) + } + + /// Documents written under one recipe could not be read under another, + /// so adding, removing or changing the declaration is incompatible. + #[test] + fn should_return_invalid_result_when_encrypted_for_is_added_removed_or_changed() { + let platform_version = PlatformVersion::latest(); + + for (old_declaration, new_declaration, changed_path) in [ + ( + None, + Some(declaration("recipientId")), + "/properties/encryptedMessage/encryptedFor", + ), + ( + Some(declaration("recipientId")), + None, + "/properties/encryptedMessage/encryptedFor", + ), + ( + Some(declaration("recipientId")), + Some(declaration("$ownerId")), + "/properties/encryptedMessage/encryptedFor/recipient", + ), + ] { + let old_document_type = encrypted_document_type(old_declaration, platform_version); + let new_document_type = encrypted_document_type(new_declaration, 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 + ); + } + } + + #[test] + fn should_return_valid_result_when_encrypted_for_is_unchanged() { + let platform_version = PlatformVersion::latest(); + + let old_document_type = + encrypted_document_type(Some(declaration("recipientId")), platform_version); + let new_document_type = + encrypted_document_type(Some(declaration("recipientId")), 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!(result.is_valid(), "{:?}", result.errors); + } + } + mod validate_byte_array_encoding { use super::*; use std::collections::BTreeMap; 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 d445e0a2b68..210dd32ce14 100644 --- a/packages/rs-dpp/src/data_contract/document_type/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/mod.rs @@ -118,6 +118,20 @@ pub(crate) mod property_names { pub const READONLY: &str = "readonly"; pub const KEEPS_HISTORY: &str = "keepsHistory"; pub const OWNER_PROTECTED: &str = "ownerProtected"; + /// Property-level object on a byte array declaring how its ciphertext was + /// produced: the [`RECIPIENT`], the [`RECIPIENT_KEY`] and [`SENDER_KEY`] + /// properties carrying the key ids, and the [`SCHEME`]. Meta-schema v3+ + /// (protocol version 14). See `apply_encrypted_for` in `try_from_schema`. + pub const ENCRYPTED_FOR: &str = "encryptedFor"; + /// `encryptedFor`: the identifier property naming the recipient identity, + /// or `$ownerId` for the writer's own. + pub const RECIPIENT: &str = "recipient"; + /// `encryptedFor`: the integer property carrying the recipient's key id. + pub const RECIPIENT_KEY: &str = "recipientKey"; + /// `encryptedFor`: the integer property carrying the sender's key id. + pub const SENDER_KEY: &str = "senderKey"; + /// `encryptedFor`: the scheme name, one of `EncryptionScheme::ALL`. + pub const SCHEME: &str = "scheme"; 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/encrypted_for.rs b/packages/rs-dpp/src/data_contract/document_type/property/encrypted_for.rs new file mode 100644 index 00000000000..71fbf9cc395 --- /dev/null +++ b/packages/rs-dpp/src/data_contract/document_type/property/encrypted_for.rs @@ -0,0 +1,230 @@ +//! The `encryptedFor` declaration of a byte array property: how its ciphertext +//! was produced, so that a wallet or SDK reads the recipe from the contract +//! instead of a side channel. +//! +//! Consensus can only check the shape of the bytes (see +//! [`EncryptionScheme::is_valid_ciphertext_length`]); who can decrypt them, +//! and whether they decrypt at all, is not verifiable on chain. + +use crate::document::property_names::OWNER_ID; +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// The `encryptedFor` declaration of a byte array property. +/// +/// Declared as +/// `"encryptedFor": { "recipient": "...", "recipientKey": "...", "senderKey": "...", "scheme": "..." }` +/// on a `byteArray` property that is not an identifier. The three paths name +/// properties of the same document type: the recipient an identifier property +/// (or the writer's own `$ownerId`), the two keys integer properties bounded +/// to `u32` that carry identity key ids. The parser checks all three exist +/// with those types when the contract is registered. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct EncryptedFor { + /// Whose keys decrypt the bytes. + pub recipient: EncryptedForRecipient, + /// Path of the integer property carrying the id of the recipient's key. + pub recipient_key: String, + /// Path of the integer property carrying the id of the sender's key. + pub sender_key: String, + /// How the bytes were produced. + pub scheme: EncryptionScheme, +} + +/// The recipient of an [`EncryptedFor`] declaration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EncryptedForRecipient { + /// The document's owner: a message the writer encrypts to themself. + Owner, + /// The identifier property, of the same document type, whose value is the + /// recipient identity's id. A dotted path when the property is nested. + Property(String), +} + +impl EncryptedForRecipient { + /// The recipient a schema path names: `$ownerId` is the owner, anything + /// else a property path. + pub fn from_path(path: &str) -> Self { + if path == OWNER_ID { + EncryptedForRecipient::Owner + } else { + EncryptedForRecipient::Property(path.to_string()) + } + } + + /// The path as the schema spells it: `$ownerId` for the owner. + pub fn as_path(&self) -> &str { + match self { + EncryptedForRecipient::Owner => OWNER_ID, + EncryptedForRecipient::Property(path) => path.as_str(), + } + } + + /// The property path when the recipient is a property, `None` for the owner. + pub fn property_path(&self) -> Option<&str> { + match self { + EncryptedForRecipient::Owner => None, + EncryptedForRecipient::Property(path) => Some(path.as_str()), + } + } +} + +impl Serialize for EncryptedForRecipient { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_path()) + } +} + +impl<'de> Deserialize<'de> for EncryptedForRecipient { + fn deserialize>(deserializer: D) -> Result { + let path = String::deserialize(deserializer)?; + Ok(EncryptedForRecipient::from_path(&path)) + } +} + +impl fmt::Display for EncryptedForRecipient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_path()) + } +} + +/// The scheme under which an [`EncryptedFor`] property's bytes were produced. +/// +/// Consensus knows the shape each scheme produces and nothing more. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EncryptionScheme { + /// The scheme the dashpay contact request uses for `encryptedPublicKey` + /// (DIP-15): the shared key is the libsecp256k1 ECDH of the sender's + /// private key and the recipient's public key, `SHA256((y & 1 | 2) || x)` + /// of the product point; the bytes are a random 16-byte IV followed by + /// the plaintext under AES-256-CBC with PKCS7 padding and that IV. So a + /// ciphertext is at least 32 bytes and always a multiple of 16. + #[serde(rename = "ecdh-secp256k1-aes256-cbc")] + EcdhSecp256k1Aes256Cbc, +} + +impl EncryptionScheme { + /// Every scheme, in wire-name order. + pub const ALL: [EncryptionScheme; 1] = [EncryptionScheme::EcdhSecp256k1Aes256Cbc]; + + /// The wire name, the value of `encryptedFor.scheme`. + pub const fn as_str(&self) -> &'static str { + match self { + EncryptionScheme::EcdhSecp256k1Aes256Cbc => "ecdh-secp256k1-aes256-cbc", + } + } + + /// The scheme a wire name names, `None` for any other name. + pub fn from_wire_name(name: &str) -> Option { + Self::ALL.into_iter().find(|scheme| scheme.as_str() == name) + } + + /// The cipher's block length in bytes; a ciphertext is a multiple of it. + pub const fn block_length(&self) -> usize { + match self { + EncryptionScheme::EcdhSecp256k1Aes256Cbc => 16, + } + } + + /// The shortest ciphertext the scheme can produce: the IV prefix plus one + /// padded block. + pub const fn minimum_ciphertext_length(&self) -> usize { + match self { + EncryptionScheme::EcdhSecp256k1Aes256Cbc => 16 + 16, + } + } + + /// Whether `length` bytes have the shape the scheme produces. This is all + /// consensus can tell about a ciphertext. + pub fn is_valid_ciphertext_length(&self, length: usize) -> bool { + length >= self.minimum_ciphertext_length() && length.is_multiple_of(self.block_length()) + } +} + +impl fmt::Display for EncryptionScheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn should_round_trip_a_declaration_through_json_with_the_schema_spelling() { + let declaration = EncryptedFor { + recipient: EncryptedForRecipient::Property("recipientId".to_string()), + recipient_key: "recipientKeyId".to_string(), + sender_key: "senderKeyId".to_string(), + scheme: EncryptionScheme::EcdhSecp256k1Aes256Cbc, + }; + let json = serde_json::to_value(&declaration).expect("should serialize"); + assert_eq!( + json, + json!({ + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }) + ); + let back: EncryptedFor = serde_json::from_value(json).expect("should deserialize"); + assert_eq!(back, declaration); + } + + #[test] + fn should_spell_the_owner_recipient_as_owner_id() { + let owner = EncryptedForRecipient::from_path("$ownerId"); + assert_eq!(owner, EncryptedForRecipient::Owner); + assert_eq!(owner.as_path(), "$ownerId"); + assert_eq!(owner.property_path(), None); + assert_eq!( + serde_json::to_value(&owner).expect("should serialize"), + json!("$ownerId") + ); + let property = EncryptedForRecipient::from_path("recipientId"); + assert_eq!(property.property_path(), Some("recipientId")); + } + + #[test] + fn should_refuse_an_unknown_scheme_and_name_every_known_one() { + assert_eq!(EncryptionScheme::from_wire_name("rsa"), None); + for scheme in EncryptionScheme::ALL { + assert_eq!( + EncryptionScheme::from_wire_name(scheme.as_str()), + Some(scheme) + ); + assert_eq!( + serde_json::to_value(scheme).expect("should serialize"), + json!(scheme.as_str()) + ); + } + } + + #[test] + fn should_accept_only_an_iv_plus_whole_blocks_for_aes_cbc() { + let scheme = EncryptionScheme::EcdhSecp256k1Aes256Cbc; + assert_eq!(scheme.minimum_ciphertext_length(), 32); + assert_eq!(scheme.block_length(), 16); + for (length, valid) in [ + (0, false), + (16, false), + (31, false), + (32, true), + (47, false), + (48, true), + (96, true), + (1040, true), + (1041, false), + ] { + assert_eq!( + scheme.is_valid_ciphertext_length(length), + valid, + "{length} bytes" + ); + } + } +} 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 c3072c15b78..53748fda1f3 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 @@ -36,6 +36,9 @@ use rand::Rng; use serde::{Deserialize, Serialize}; pub mod array; +pub mod encrypted_for; + +pub use encrypted_for::{EncryptedFor, EncryptedForRecipient, EncryptionScheme}; #[cfg(test)] mod byte_array_encoding_flip_tests; @@ -59,6 +62,11 @@ pub struct DocumentProperty { /// is every property parsed before protocol version 14. #[serde(skip_serializing_if = "Option::is_none")] pub distinct_from: Option, + /// How the property's bytes were encrypted (`encryptedFor`): the recipient, + /// the key ids and the scheme. Only ever `Some` on a byte array property, + /// and only on contracts parsed from protocol version 14 on. + #[serde(skip_serializing_if = "Option::is_none")] + pub encrypted_for: Option, } /// What a `distinctFrom` identifier property must differ from. @@ -3626,6 +3634,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -3636,6 +3645,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -6326,6 +6336,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); inner_fields.insert( @@ -6336,6 +6347,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6388,6 +6400,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6409,6 +6422,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); inner_fields.insert( @@ -6419,6 +6433,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6853,6 +6868,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -6889,6 +6905,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -6899,6 +6916,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -6918,6 +6936,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -6928,6 +6947,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let obj = DocumentPropertyType::Object(sub_fields); @@ -7221,6 +7241,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -7231,6 +7252,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -7292,6 +7314,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -7302,6 +7325,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -7389,6 +7413,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); sub_fields.insert( @@ -7399,6 +7424,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -7666,6 +7692,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7696,6 +7723,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); // Second field is required @@ -7707,6 +7735,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7824,6 +7853,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -7843,6 +7873,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(inner_fields); @@ -8150,6 +8181,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }, ); let prop = DocumentPropertyType::Object(sub_fields); @@ -8414,6 +8446,7 @@ mod tests { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, }; let value = serde_json::to_value(&property).expect("serialization should succeed"); diff --git a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs index d659e4d1e5e..29d7eff1a77 100644 --- a/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs +++ b/packages/rs-dpp/src/data_contract/document_type/v0/random_document_type.rs @@ -199,6 +199,7 @@ impl DocumentTypeV0 { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, } }; @@ -592,6 +593,7 @@ impl DocumentTypeV0 { transient: false, required_since: None, distinct_from: None, + encrypted_for: None, } }; diff --git a/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs b/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs index e62db2c432a..3c2bbecfd31 100644 --- a/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs +++ b/packages/rs-dpp/src/data_contract/v1/serialization/mod.rs @@ -252,4 +252,116 @@ mod tests { assert_eq!(v1.owner_id(), recovered.owner_id()); assert_eq!(v1.version(), recovered.version()); } + + /// A `secret` document type whose `encryptedMessage` carries `encryptedFor` + /// when `encrypted` is set, and the same type without the keyword otherwise. + #[cfg(feature = "random-identities")] + fn secret_schema(encrypted: bool) -> platform_value::Value { + use platform_value::platform_value; + + let mut encrypted_message = platform_value!({ + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 1040, + "position": 3 + }); + if encrypted { + encrypted_message + .insert( + "encryptedFor".to_string(), + platform_value!({ + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + }), + ) + .expect("expected to insert encryptedFor"); + } + platform_value!({ + "type": "object", + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 1 }, + "senderKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 2 }, + "encryptedMessage": encrypted_message + }, + "required": ["recipientId", "recipientKeyId", "senderKeyId", "encryptedMessage"], + "additionalProperties": false + }) + } + + /// The declaration lives in the document schema the contract serializes, so + /// a contract carrying it round-trips like any other and comes back with + /// the declaration parsed; one without it serializes exactly as before. + #[test] + #[cfg(feature = "random-identities")] + fn should_round_trip_a_contract_with_and_without_encrypted_for_through_platform_serialization() + { + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; + use crate::data_contract::document_type::{ + EncryptedFor, EncryptedForRecipient, EncryptionScheme, + }; + use crate::data_contract::schema::DataContractSchemaMethodsV0; + + let platform_version = PlatformVersion::latest(); + let identity = Identity::random_identity(5, Some(5), platform_version) + .expect("expected a random identity"); + + for encrypted in [true, false] { + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + contract + .set_document_schema( + "secret", + secret_schema(encrypted), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the secret document type"); + + let bytes = contract + .serialize_to_bytes_with_platform_version(platform_version) + .expect("expected to serialize"); + let recovered = + DataContract::versioned_deserialize_untrusted(&bytes, true, platform_version) + .expect("expected to deserialize"); + assert_eq!(contract, recovered); + + let secret_type = recovered + .document_type_for_name("secret") + .expect("expected the secret document type"); + let encrypted_properties = secret_type.encrypted_properties(); + if encrypted { + assert_eq!( + encrypted_properties, + vec![( + &"encryptedMessage".to_string(), + &EncryptedFor { + recipient: EncryptedForRecipient::Property("recipientId".to_string()), + recipient_key: "recipientKeyId".to_string(), + sender_key: "senderKeyId".to_string(), + scheme: EncryptionScheme::EcdhSecp256k1Aes256Cbc, + } + )] + ); + } else { + assert!(encrypted_properties.is_empty()); + } + } + } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs index 82cfff715b3..75960594ef7 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -57,7 +57,7 @@ use crate::consensus::basic::document::{ DocumentPropertyNotDistinctError, DocumentTransitionsAreAbsentError, DuplicateDocumentTransitionsWithIdsError, DuplicateDocumentTransitionsWithIndicesError, InconsistentCompoundIndexDataError, InvalidDocumentTransitionActionError, - InvalidDocumentTransitionIdError, InvalidDocumentTypeError, + InvalidDocumentTransitionIdError, InvalidDocumentTypeError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingDataContractIdBasicError, MissingDocumentTransitionActionError, MissingDocumentTransitionTypeError, MissingDocumentTypeError, MissingPositionsInDocumentTypePropertiesError, NonceOutOfBoundsError, @@ -808,6 +808,10 @@ pub enum BasicError { #[error(transparent)] DocumentPropertyNotDistinctError(DocumentPropertyNotDistinctError), + + // The shape of an `encryptedFor` property's ciphertext (protocol version 14). + #[error(transparent)] + InvalidEncryptedPropertyShapeError(InvalidEncryptedPropertyShapeError), } impl From for ConsensusError { @@ -893,7 +897,7 @@ mod tests { 193 ); // A `distinctFrom` identifier property equal to what it must differ from (protocol - // version 14): the tail of the enum. + // version 14). assert_eq!( discriminant_of(BasicError::DocumentPropertyNotDistinctError( DocumentPropertyNotDistinctError::new( @@ -904,5 +908,19 @@ mod tests { )), 194 ); + // The shape of an `encryptedFor` property's ciphertext (protocol version 14): the + // tail of the enum. + assert_eq!( + discriminant_of(BasicError::InvalidEncryptedPropertyShapeError( + InvalidEncryptedPropertyShapeError::new( + "encryptedMessage".to_string(), + "ecdh-secp256k1-aes256-cbc".to_string(), + 47, + 32, + 16 + ) + )), + 195 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/invalid_encrypted_property_shape_error.rs b/packages/rs-dpp/src/errors/consensus/basic/document/invalid_encrypted_property_shape_error.rs new file mode 100644 index 00000000000..64066468381 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/document/invalid_encrypted_property_shape_error.rs @@ -0,0 +1,93 @@ +use crate::consensus::basic::BasicError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A document supplied a value for a property its type declares `encryptedFor` +/// whose length is not one the declared scheme produces: at least the IV plus +/// one block, and a multiple of the block length. The shape is all consensus +/// can check about a ciphertext. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Eq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error( + "Property {property} is declared encrypted under {scheme}, but its {actual_length} bytes are \ + not a ciphertext of that scheme: at least {minimum_length} bytes and a multiple of \ + {block_length} are required" +)] +#[platform_serialize(unversioned)] +pub struct InvalidEncryptedPropertyShapeError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + property: String, + scheme: String, + actual_length: u32, + minimum_length: u32, + block_length: u32, +} + +impl InvalidEncryptedPropertyShapeError { + pub fn new( + property: String, + scheme: String, + actual_length: u32, + minimum_length: u32, + block_length: u32, + ) -> Self { + Self { + property, + scheme, + actual_length, + minimum_length, + block_length, + } + } + + /// The dotted path of the property, as the document type flattens it. + pub fn property(&self) -> &str { + &self.property + } + + /// The wire name of the scheme the property is declared under. + pub fn scheme(&self) -> &str { + &self.scheme + } + + /// The length the document supplied. + pub fn actual_length(&self) -> u32 { + self.actual_length + } + + /// The shortest ciphertext the scheme produces. + pub fn minimum_length(&self) -> u32 { + self.minimum_length + } + + /// The block length a ciphertext of the scheme is a multiple of. + pub fn block_length(&self) -> u32 { + self.block_length + } +} + +impl From for ConsensusError { + fn from(err: InvalidEncryptedPropertyShapeError) -> Self { + Self::BasicError(BasicError::InvalidEncryptedPropertyShapeError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs index f784691f447..940fd3cbf34 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/document/mod.rs @@ -11,6 +11,7 @@ mod inconsistent_compound_index_data_error; mod invalid_document_transition_action_error; mod invalid_document_transition_id_error; mod invalid_document_type_error; +mod invalid_encrypted_property_shape_error; mod max_documents_transitions_exceeded_error; mod missing_data_contract_id_basic_error; mod missing_document_transition_action_error; @@ -31,6 +32,7 @@ pub use inconsistent_compound_index_data_error::*; pub use invalid_document_transition_action_error::*; pub use invalid_document_transition_id_error::*; pub use invalid_document_type_error::*; +pub use invalid_encrypted_property_shape_error::*; pub use max_documents_transitions_exceeded_error::*; pub use missing_data_contract_id_basic_error::*; pub use missing_document_transition_action_error::*; diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index e6de0db7561..541e717adf8 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -167,6 +167,7 @@ impl ErrorWithCode for BasicError { Self::DocumentFieldMaxSizeExceededError(_) => 10417, Self::ContestedDocumentsTemporarilyNotAllowedError(_) => 10418, Self::DocumentPropertyNotDistinctError(_) => 10419, + Self::InvalidEncryptedPropertyShapeError(_) => 10420, // Token Errors: 10450-10499 Self::InvalidTokenIdError(_) => 10450, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs index 234e5e0cbd8..50123948016 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_create_transition_action/advanced_structure_v1/mod.rs @@ -6,7 +6,7 @@ use dpp::consensus::state::document::document_contest_not_required_error::Docume use dpp::dashcore::Network; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::methods::{DocumentTypeBasicMethods, DocumentTypeV0Methods}; use dpp::data_contract::document_type::restricted_creation::CreationRestrictionMode; use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; use dpp::identifier::Identifier; @@ -161,8 +161,18 @@ impl DocumentCreateTransitionActionStructureValidationV1 for DocumentCreateTrans // property or from the writer's `$ownerId`. Both are on the transition, so // this is a structure check; it runs after the schema validation above so // every value it compares is already known to be a 32-byte identifier. - document_type + let result = document_type .validate_distinct_from_properties(self.data(), owner_id, platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + + // The schema validation above established every supplied value is a byte array + // where the type says so; what is left is whether an `encryptedFor` property has + // the shape its scheme produces, which is all consensus can tell about a ciphertext. + document_type + .validate_encrypted_property_shapes(self.data(), platform_version) .map_err(Error::Protocol) // -->> End Introduced in V1 <<-- } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs index 43ea70a0dd0..c62af0551ee 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_replace_transition_action/advanced_structure_v0/mod.rs @@ -1,7 +1,7 @@ use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::methods::{DocumentTypeBasicMethods, DocumentTypeV0Methods}; use dpp::identifier::Identifier; use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; use dpp::validation::SimpleConsensusValidationResult; @@ -62,8 +62,23 @@ impl DocumentReplaceTransitionActionStructureValidationV0 for DocumentReplaceTra // `distinctFrom` identifier property must differ from the named sibling property or // from the writer's `$ownerId`; both are on the transition, and the schema // validation above already made every value compared a 32-byte identifier. - document_type + let result = document_type .validate_distinct_from_properties(self.data(), owner_id, platform_version) + .map_err(Error::Protocol)?; + if !result.is_valid() { + return Ok(result); + } + + // Added in place at protocol version 14, inert for every earlier version this + // generation serves: their meta-schemas refuse `encryptedFor`, their parser + // ignores it (`apply_encrypted_for` is `None`, so no parsed property carries a + // declaration), and `validate_encrypted_property_shapes` is `None` there, so the + // call returns an empty result. From 14, + // an `encryptedFor` property the replace supplies must have the shape its scheme + // produces, which is all consensus can tell about a ciphertext; the schema + // validation above already made every such value a byte array. + document_type + .validate_encrypted_property_shapes(self.data(), platform_version) .map_err(Error::Protocol) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs new file mode 100644 index 00000000000..eb633bde47a --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/encrypted_for.rs @@ -0,0 +1,512 @@ +//! End-to-end coverage for the `encryptedFor` property keyword (protocol +//! version 14): a byte array property declaring how its ciphertext was made. +//! Consensus checks only the shape of the bytes, on every create and replace: +//! at least the IV plus one block and a multiple of the block for AES-CBC. +//! A value of the right shape is accepted, a value of any other shape is +//! consensus-rejected and leaves the stored document untouched. + +use super::*; + +mod encrypted_for_tests { + use super::*; + use crate::execution::validation::state_transition::batch::action_validation::document::document_replace_transition_action::DocumentReplaceTransitionActionValidation; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::basic::BasicError; + use dpp::tokens::gas_fees_paid_by::GasFeesPaidBy; + use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::{DocumentBaseTransitionAction, DocumentBaseTransitionActionV0}; + use drive::state_transition_action::batch::batched_transition::document_transition::document_replace_transition_action::{DocumentReplaceTransitionAction, DocumentReplaceTransitionActionV0}; + use std::collections::{BTreeMap, BTreeSet}; + use dpp::data_contract::schema::DataContractSchemaMethodsV0; + use dpp::document::Document; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::platform_value; + use dpp::prelude::Identifier; + use dpp::prelude::{DataContract, IdentityNonce}; + use dpp::state_transition::StateTransition; + use dpp::tests::fixtures::get_data_contract_fixture; + use drive::util::storage_flags::StorageFlags; + use simple_signer::signer::SimpleSigner; + + /// A mutable `secret` type: the recipient identifier, the two key ids, + /// the `encryptedMessage` declared `encryptedFor`, and an optional `meta` + /// object whose `blob` is declared the same way (a nested, dotted path). + /// The byte arrays' own `minItems` are left at 1 so that every rejection + /// below is the shape check's: the JSON schema's bounds run first, and a + /// 16-byte value against `minItems: 32` would be refused as a schema error + /// before the shape check ever saw it. + fn secret_schema() -> Value { + platform_value!({ + "type": "object", + "documentsMutable": true, + "properties": { + "recipientId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "position": 0 + }, + "recipientKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 1 }, + "senderKeyId": { "type": "integer", "minimum": 0, "maximum": 4294967295_u64, "position": 2 }, + "encryptedMessage": { + "type": "array", + "byteArray": true, + "minItems": 1, + "maxItems": 1040, + "position": 3, + "encryptedFor": { + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + } + }, + "meta": { + "type": "object", + "position": 4, + "properties": { + "blob": { + "type": "array", + "byteArray": true, + "minItems": 1, + "maxItems": 1040, + "position": 0, + "encryptedFor": { + "recipient": "recipientId", + "recipientKey": "recipientKeyId", + "senderKey": "senderKeyId", + "scheme": "ecdh-secp256k1-aes256-cbc" + } + } + }, + "additionalProperties": false + } + }, + "required": ["recipientId", "recipientKeyId", "senderKeyId", "encryptedMessage"], + "additionalProperties": false + }) + } + + /// One identity and one contract whose `secret` type is the one above. + struct SecretFixture { + platform: TempPlatform, + signer: SimpleSigner, + key: IdentityPublicKey, + identity: Identity, + contract: DataContract, + /// The identity contract nonce the next transition uses. Every + /// processed transition consumes one, including the ones that fail + /// with a paid consensus error. + next_nonce: IdentityNonce, + } + + impl SecretFixture { + fn new() -> Self { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.5)); + + let mut contract = get_data_contract_fixture( + Some(identity.id()), + 0, + platform_version.protocol_version, + ) + .data_contract_owned(); + contract + .set_document_schema( + "secret", + secret_schema(), + true, + &mut Vec::new(), + platform_version, + ) + .expect("expected to add the secret document type"); + platform + .drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply the contract"); + + Self { + platform, + signer, + key, + identity, + contract, + next_nonce: 1, + } + } + + /// A secret addressed to a fixed recipient, whose `encryptedMessage` + /// is `ciphertext_length` bytes long. + fn secret(&self, ciphertext_length: usize) -> (Document, [u8; 32]) { + let platform_version = PlatformVersion::latest(); + let secret_type = self + .contract + .document_type_for_name("secret") + .expect("expected the secret document type"); + let mut rng = StdRng::seed_from_u64(433); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut secret = secret_type + .random_document_with_identifier_and_entropy( + &mut rng, + self.identity.id(), + entropy, + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random secret"); + secret + .set_id_for_creation(secret_type, &entropy.0, self.next_nonce, platform_version) + .expect("expected to set the document id"); + secret.set("recipientId", Value::Identifier([7u8; 32])); + secret.set("recipientKeyId", Value::U32(3)); + secret.set("senderKeyId", Value::U32(1)); + secret.set( + "encryptedMessage", + Value::Bytes(vec![0xAB; ciphertext_length]), + ); + (secret, entropy.0) + } + + async fn create( + &mut self, + ciphertext_length: usize, + ) -> (Document, StateTransitionExecutionResult) { + self.create_with(ciphertext_length, |_| {}).await + } + + /// Like `create`, with `mutate` applied to the secret before it is sent. + async fn create_with( + &mut self, + ciphertext_length: usize, + mutate: impl FnOnce(&mut Document), + ) -> (Document, StateTransitionExecutionResult) { + let platform_version = PlatformVersion::latest(); + let (mut secret, entropy) = self.secret(ciphertext_length); + mutate(&mut secret); + let transition = { + let secret_type = self + .contract + .document_type_for_name("secret") + .expect("expected the secret document type"); + BatchTransition::new_document_creation_transition_from_document( + secret.clone(), + secret_type, + entropy, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the create transition") + }; + self.next_nonce += 1; + (secret, self.process(&transition)) + } + + /// Replaces `stored` with its `encryptedMessage` set to + /// `ciphertext_length` bytes and the revision bumped. + async fn replace( + &mut self, + stored: &Document, + ciphertext_length: usize, + ) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let mut replacement = stored.clone(); + replacement.set( + "encryptedMessage", + Value::Bytes(vec![0xCD; ciphertext_length]), + ); + replacement + .increment_revision() + .expect("expected the revision to increment"); + let transition = { + let secret_type = self + .contract + .document_type_for_name("secret") + .expect("expected the secret document type"); + BatchTransition::new_document_replacement_transition_from_document( + replacement, + secret_type, + &self.key, + self.next_nonce, + 0, + None, + &self.signer, + platform_version, + None, + ) + .await + .expect("expected the replace transition") + }; + self.next_nonce += 1; + self.process(&transition) + } + + fn process(&self, transition: &StateTransition) -> StateTransitionExecutionResult { + let platform_version = PlatformVersion::latest(); + let platform_state = self.platform.state.load(); + let serialized = transition + .serialize_to_bytes() + .expect("expected the transition to serialize"); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the state transition"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// The stored secrets, read back from Drive. + fn stored_secrets(&self) -> Vec { + let platform_version = PlatformVersion::latest(); + let query = DriveDocumentQuery::from_sql_expr( + "select * from secret", + &self.contract, + Some(&self.platform.config.drive), + platform_version, + ) + .expect("expected a document query"); + self.platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected a query result") + .documents() + .to_vec() + } + } + + fn expect_shape_error(result: StateTransitionExecutionResult, actual_length: u32) { + expect_shape_error_on(result, "encryptedMessage", actual_length); + } + + fn expect_shape_error_on( + result: StateTransitionExecutionResult, + property: &str, + actual_length: u32, + ) { + let StateTransitionExecutionResult::PaidConsensusError { error, .. } = result else { + panic!("expected a paid consensus error, got {result:?}"); + }; + let ConsensusError::BasicError(BasicError::InvalidEncryptedPropertyShapeError(error)) = + error + else { + panic!("expected an InvalidEncryptedPropertyShapeError, got {error:?}"); + }; + assert_eq!(error.property(), property); + assert_eq!(error.scheme(), "ecdh-secp256k1-aes256-cbc"); + assert_eq!(error.actual_length(), actual_length); + assert_eq!(error.minimum_length(), 32); + assert_eq!(error.block_length(), 16); + } + + #[tokio::test] + async fn should_create_a_document_whose_ciphertext_is_an_iv_plus_whole_blocks() { + let mut fixture = SecretFixture::new(); + + let (secret, result) = fixture.create(48).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + let stored = fixture.stored_secrets(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].get("encryptedMessage"), + secret.get("encryptedMessage") + ); + } + + #[tokio::test] + async fn should_refuse_a_ciphertext_that_is_not_a_multiple_of_the_block() { + let mut fixture = SecretFixture::new(); + + let (_, result) = fixture.create(47).await; + + expect_shape_error(result, 47); + assert!(fixture.stored_secrets().is_empty()); + } + + #[tokio::test] + async fn should_refuse_a_ciphertext_of_the_iv_alone() { + let mut fixture = SecretFixture::new(); + + let (_, result) = fixture.create(16).await; + + expect_shape_error(result, 16); + assert!(fixture.stored_secrets().is_empty()); + } + + /// The nested declaration is checked through its dotted path, and a + /// declared property the document leaves out is not checked at all. + #[tokio::test] + async fn should_check_a_nested_encrypted_property_and_skip_an_omitted_one() { + let mut fixture = SecretFixture::new(); + + let (_, result) = fixture + .create_with(48, |secret| { + secret.set( + "meta", + platform_value!({ "blob": Value::Bytes(vec![0xEF; 47]) }), + ); + }) + .await; + expect_shape_error_on(result, "meta.blob", 47); + assert!(fixture.stored_secrets().is_empty()); + + let (_, result) = fixture + .create_with(48, |secret| { + secret.set( + "meta", + platform_value!({ "blob": Value::Bytes(vec![0xEF; 64]) }), + ); + }) + .await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(fixture.stored_secrets().len(), 1); + } + + /// The replace structure dispatcher on both sides of the gate: structure + /// generation 0 gained the shape check in place, so at protocol version 13 + /// it must still accept the action (no property parsed there carries the + /// keyword and the dpp gate is `None`), and at 14 refuse it. The action is + /// built by hand the way the transformer would build it, against the + /// contract as Drive hands it back. + #[test] + fn should_not_check_the_ciphertext_shape_on_replace_before_protocol_version_14() { + let platform_version = PlatformVersion::latest(); + let fixture = SecretFixture::new(); + let owner_id = fixture.identity.id(); + + let (_, contract_fetch_info) = fixture + .platform + .drive + .get_contract_with_fetch_info_and_fee( + fixture.contract.id().to_buffer(), + None, + false, + None, + platform_version, + ) + .expect("expected to fetch the contract"); + let contract_fetch_info = contract_fetch_info.expect("the contract is in state"); + + let action = DocumentReplaceTransitionAction::V0(DocumentReplaceTransitionActionV0 { + base: DocumentBaseTransitionAction::V0(DocumentBaseTransitionActionV0 { + id: Identifier::from([0xAA; 32]), + identity_contract_nonce: 1, + document_type_name: "secret".to_string(), + data_contract: contract_fetch_info, + token_cost: None, + gas_fees_paid_by: GasFeesPaidBy::default(), + contract_gas_fees_paid_by: GasFeesPaidBy::default(), + declared_action_fee: None, + }), + revision: 2, + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + data: BTreeMap::from([ + ("recipientId".to_string(), Value::Identifier([7u8; 32])), + ("recipientKeyId".to_string(), Value::U32(3)), + ("senderKeyId".to_string(), Value::U32(1)), + ("encryptedMessage".to_string(), Value::Bytes(vec![0xAB; 47])), + ]), + changed_data_fields: BTreeSet::new(), + added_data_fields: BTreeSet::new(), + removed_identifier_fields: BTreeMap::new(), + creator_id: None, + }); + + let before = action + .validate_structure( + owner_id, + PlatformVersion::get(13).expect("platform version 13 should exist"), + ) + .expect("structure validation should run"); + assert!( + before.is_valid(), + "structure generation 0 must not check the ciphertext shape: {:?}", + before.errors + ); + + let at = action + .validate_structure(owner_id, platform_version) + .expect("structure validation should run"); + assert_matches!( + at.errors.as_slice(), + [ConsensusError::BasicError(BasicError::InvalidEncryptedPropertyShapeError(e))] + if e.property() == "encryptedMessage" && e.actual_length() == 47 + ); + } + + #[tokio::test] + async fn should_refuse_a_replace_that_shrinks_the_ciphertext_below_the_iv_plus_a_block() { + let mut fixture = SecretFixture::new(); + let (secret, result) = fixture.create(48).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + + let result = fixture.replace(&secret, 16).await; + + expect_shape_error(result, 16); + let stored = fixture.stored_secrets(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].get("encryptedMessage"), + secret.get("encryptedMessage"), + "the refused replace must leave the stored ciphertext untouched" + ); + + // A replace of the right shape still goes through + let result = fixture.replace(&secret, 32).await; + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + } +} 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 fe7a086d141..81b3f6f2c68 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 @@ -4,6 +4,7 @@ mod deletable_document_reference; mod deletion; mod distinct_from; mod dpns; +mod encrypted_for; mod gas_sponsorship; mod id_reuse; mod immutable; diff --git a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs index 517b201314d..10965652c00 100644 --- a/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs +++ b/packages/rs-json-schema-compatibility-validator/src/rules/rule_set.rs @@ -1199,6 +1199,48 @@ pub static KEYWORD_COMPATIBILITY_RULES: Lazy = Laz ], }, ), + ( + // How a byte array property's ciphertext was produced: documents written + // under one recipe could not be read under another, so nothing about it + // may change. + "encryptedFor", + CompatibilityRules { + allow_addition: false, + allow_removal: false, + allow_replacement_callback: FALSE_CALLBACK.clone(), + subschema_levels_depth: None, + inner: None, + #[cfg(any(test, feature = "examples"))] + examples: vec![ + ( + json!({}), + json!({ "encryptedFor": { "recipient": "recipientId", "recipientKey": "recipientKeyId", "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" } }), + Some(JsonSchemaChange::Add(AddOperation { + path: "/encryptedFor".to_string(), + value: json!({ "recipient": "recipientId", "recipientKey": "recipientKeyId", "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" }), + })), + ) + .into(), + ( + json!({ "encryptedFor": { "recipient": "recipientId", "recipientKey": "recipientKeyId", "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" } }), + json!({}), + Some(JsonSchemaChange::Remove(RemoveOperation { + path: "/encryptedFor".to_string(), + })), + ) + .into(), + ( + json!({ "encryptedFor": { "recipient": "recipientId", "recipientKey": "recipientKeyId", "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" } }), + json!({ "encryptedFor": { "recipient": "$ownerId", "recipientKey": "recipientKeyId", "senderKey": "senderKeyId", "scheme": "ecdh-secp256k1-aes256-cbc" } }), + Some(JsonSchemaChange::Replace(ReplaceOperation { + path: "/encryptedFor/recipient".to_string(), + value: json!("$ownerId"), + })), + ) + .into(), + ], + }, + ), ( "byteArray", CompatibilityRules { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs index 270c1be4fd7..2f55e2dd4c6 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/mod.rs @@ -79,6 +79,11 @@ pub struct DocumentTypeMethodVersions { /// property equals the value it must differ from. `None` on versions that /// predate the keyword, where no parsed property carries it. pub validate_distinct_from: OptionalFeatureVersion, + /// `validate_encrypted_property_shapes`: refuses a document whose `encryptedFor` + /// property does not have the shape its scheme produces. `None` on versions + /// that predate the keyword: the method returns an empty result there, so the + /// shipped create and replace structure validations that call it are inert. + pub validate_encrypted_property_shapes: OptionalFeatureVersion, } #[derive(Clone, Debug, Default)] @@ -102,6 +107,12 @@ pub struct DocumentTypeSchemaVersions { /// predate the keyword: they ignore it entirely, exactly as they parsed /// before it existed. pub apply_distinct_from: OptionalFeatureVersion, + /// Parses the `encryptedFor` property keyword (how a byte array property's + /// ciphertext was produced: recipient, key ids and scheme) onto the + /// property, and checks the properties it names at contract registration. + /// `None` on versions that predate the keyword: they ignore it entirely, + /// exactly as they parsed before it existed. + pub apply_encrypted_for: OptionalFeatureVersion, /// Parses a typed array property (`type: "array"` with an `items` /// element schema instead of `byteArray`). `None` on versions that /// predate typed arrays: they leave such a property to the scalar diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs index de3851a188c..a8d3161f6c4 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v1.rs @@ -46,6 +46,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { apply_property_reference: None, apply_required_since: None, apply_distinct_from: None, + apply_encrypted_for: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -65,6 +66,7 @@ pub const CONTRACT_VERSIONS_V1: DPPContractVersions = DPPContractVersions { serialize_value_for_key: 0, deserialize_value_for_key: 0, validate_distinct_from: None, + validate_encrypted_property_shapes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs index 22d572bc79f..5f35727e72f 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v2.rs @@ -46,6 +46,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { apply_property_reference: None, apply_required_since: None, apply_distinct_from: None, + apply_encrypted_for: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -65,6 +66,7 @@ pub const CONTRACT_VERSIONS_V2: DPPContractVersions = DPPContractVersions { serialize_value_for_key: 0, deserialize_value_for_key: 0, validate_distinct_from: None, + validate_encrypted_property_shapes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs index deaf97b1170..0c21c7188d4 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v3.rs @@ -48,6 +48,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { apply_property_reference: None, apply_required_since: None, apply_distinct_from: None, + apply_encrypted_for: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -67,6 +68,7 @@ pub const CONTRACT_VERSIONS_V3: DPPContractVersions = DPPContractVersions { serialize_value_for_key: 0, deserialize_value_for_key: 0, validate_distinct_from: None, + validate_encrypted_property_shapes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs index e9fda9c95ed..4e381e26d00 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v4.rs @@ -48,6 +48,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { apply_property_reference: None, apply_required_since: None, apply_distinct_from: None, + apply_encrypted_for: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -67,6 +68,7 @@ pub const CONTRACT_VERSIONS_V4: DPPContractVersions = DPPContractVersions { serialize_value_for_key: 0, deserialize_value_for_key: 0, validate_distinct_from: None, + validate_encrypted_property_shapes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs index 485826b7dff..afc06a4c41d 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v5.rs @@ -50,6 +50,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { apply_property_reference: None, apply_required_since: None, apply_distinct_from: None, + apply_encrypted_for: None, parse_typed_array: None, validate_max_depth: 0, max_depth: 256, @@ -69,6 +70,7 @@ pub const CONTRACT_VERSIONS_V5: DPPContractVersions = DPPContractVersions { serialize_value_for_key: 0, deserialize_value_for_key: 0, validate_distinct_from: None, + validate_encrypted_property_shapes: None, }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs index 94333ac2306..a43ce0d7464 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_contract_versions/v6.rs @@ -83,6 +83,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { apply_property_reference: Some(0), // changed: the meta-schema v3 `refersTo` keyword is folded into the parsed property type; None before this version means the keyword is ignored, as it was before it existed apply_required_since: Some(0), // changed: the meta-schema v3 `requiredSince` keyword (contract version a property is required from) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed apply_distinct_from: Some(0), // changed: the meta-schema v3 `distinctFrom` keyword (an identifier property whose value must differ from a named sibling property or the document's `$ownerId`) is parsed onto the property; None before this version means the keyword is ignored, as it was before it existed + apply_encrypted_for: Some(0), // changed: the meta-schema v3 `encryptedFor` keyword (recipient, key ids and scheme of a byte array property's ciphertext) is parsed onto the property and its named properties are checked at registration; None before this version means the keyword is ignored, as it was before it existed parse_typed_array: Some(0), // changed: a meta-schema v3 typed array (`type: "array"` with an `items` element schema) parses to `DocumentPropertyType::TypedArray`; None before this version leaves it to the scalar parser, which refuses an array that is not a byte array validate_max_depth: 0, max_depth: 256, @@ -113,6 +114,7 @@ pub const CONTRACT_VERSIONS_V6: DPPContractVersions = DPPContractVersions { serialize_value_for_key: 0, deserialize_value_for_key: 0, validate_distinct_from: Some(0), // changed: `validate_distinct_from_properties` refuses a document whose `distinctFrom` property equals what it must differ from (DocumentPropertyNotDistinctError, 10419); None before this version, where no property can carry the keyword + validate_encrypted_property_shapes: Some(0), // changed: refuses an `encryptedFor` property whose bytes are not the shape its scheme produces; None before this version returns an empty result }, }, token_versions: TokenVersions { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 82989d48037..e7802f8a616 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -9,7 +9,9 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // PROTOCOL_VERSION_14: bump `document_create_transition_structure_validation` to // 1, which cross-checks the index named by a document create transition's // prefunded voting balance against the contested index the document itself -// resolves to. Also bump document create state validation to 2, adding +// resolves to, and checks the ciphertext shape of every `encryptedFor` +// property (replace structure validation 0 gained the same shape check in +// place, inert before this version). Also bump document create state validation to 2, adding // `refersTo` document reference validation (referenced identities and // contracts must exist) and rejecting a non-contested create whose id is // already held by a live contested document. Document replace state diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 9a630293657..0284951cb7d 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -600,6 +600,25 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// passes. The parser checks the target at contract /// registration and update (it must exist, be an identifier and not be /// the declaring property), and a changed `distinctFrom` is an +/// +/// 27. **`encryptedFor` on byte array properties**: a byte array property may +/// declare how its ciphertext was produced, so wallets read the recipe +/// from the contract instead of a side channel: `recipient` (an identifier +/// property of the same document type, or `$ownerId`), `recipientKey` and +/// `senderKey` (integer properties of the same type bounded to u32, +/// carrying key ids) and `scheme` (`ecdh-secp256k1-aes256-cbc`, the +/// dashpay contact request scheme: a 16-byte IV followed by AES-256-CBC +/// with PKCS7 padding under the ECDH shared key). Meta-schema v3 admits it +/// on byte arrays that are not identifiers, `apply_encrypted_for` 0 parses +/// it onto `DocumentProperty::encrypted_for` and checks the three named +/// properties exist with the right types at registration. Document create +/// structure validation 1 and replace structure validation 0 (extended in +/// place, inert before this version) call +/// `validate_encrypted_property_shapes` (`validate_encrypted_property_shapes` +/// 0, `None` before this version) to check the ciphertext shape of every declared property a transition supplies, +/// at least the IV plus one block and a multiple of the block, and refuse +/// it with `InvalidEncryptedPropertyShapeError` (10420). Nothing else about +/// the ciphertext is verifiable on chain. A changed `encryptedFor` is an /// incompatible schema change on update. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 11b79dc99d4..c37181e216b 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -67,7 +67,7 @@ use dpp::consensus::state::data_trigger::DataTriggerError::{ }; use wasm_bindgen::{JsError, JsValue}; use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, DataContractInvalidRequiredFieldsUpdateError, ContestedUniqueIndexWithUniqueIndexError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, PreProgrammedDistributionAmountOverLimitError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; -use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyNotDistinctError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; +use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, DocumentPropertyNotDistinctError, InvalidEncryptedPropertyShapeError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; use dpp::consensus::basic::overflow_error::OverflowError; @@ -1267,6 +1267,9 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::InvalidContractModerationReasonDocumentsError(e) => { generic_consensus_error!(InvalidContractModerationReasonDocumentsError, e).into() } + BasicError::InvalidEncryptedPropertyShapeError(e) => { + generic_consensus_error!(InvalidEncryptedPropertyShapeError, e).into() + } } } diff --git a/packages/wasm-dpp2/src/consensus_error.rs b/packages/wasm-dpp2/src/consensus_error.rs index 7a96997b8a3..54892948e17 100644 --- a/packages/wasm-dpp2/src/consensus_error.rs +++ b/packages/wasm-dpp2/src/consensus_error.rs @@ -144,6 +144,41 @@ impl DocumentDistinctFromErrorCodeWasm { } } +/// Consensus error codes emitted by `encryptedFor` validation, which runs +/// from protocol version 14 onward on every document create and replace. +/// +/// Branch on an error's `code` against these instead of matching its +/// message: +/// +/// ```js +/// try { +/// await sdk.documents.create({ document, identityKey, signer }); +/// } catch (e) { +/// if (e.code === DocumentEncryptionErrorCode.InvalidEncryptedPropertyShape) { +/// // the bytes are not the shape the declared scheme produces +/// } +/// } +/// ``` +#[wasm_bindgen(js_name = "DocumentEncryptionErrorCode")] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum DocumentEncryptionErrorCodeWasm { + /// A property the document type declares `encryptedFor` was supplied + /// with bytes that are not a ciphertext of the declared scheme: shorter + /// than the IV plus one block, or not a multiple of the block length. + /// The shape is all consensus checks about a ciphertext. + InvalidEncryptedPropertyShape = 10420, +} + +impl DocumentEncryptionErrorCodeWasm { + /// The encryption error a code names, or `None` for any other code. + fn from_code(code: u32) -> Option { + match code { + 10420 => Some(Self::InvalidEncryptedPropertyShape), + _ => None, + } + } +} + #[wasm_bindgen(js_name = "ConsensusError")] pub struct ConsensusErrorWasm(ConsensusError); @@ -191,6 +226,12 @@ impl ConsensusErrorWasm { pub fn document_distinct_from_error_code(&self) -> Option { DocumentDistinctFromErrorCodeWasm::from_code(self.0.code()) } + /// The encrypted-property error this is, or `undefined` when it is not + /// code 10420. + #[wasm_bindgen(getter = "documentEncryptionErrorCode")] + pub fn document_encryption_error_code(&self) -> Option { + DocumentEncryptionErrorCodeWasm::from_code(self.0.code()) + } } impl_wasm_type_info!(ConsensusErrorWasm, ConsensusError); @@ -275,6 +316,40 @@ mod tests { assert_eq!(DocumentDistinctFromErrorCodeWasm::from_code(10418), None); } + /// Built from the real DPP error rather than a code literal, like the + /// immutability test above. + #[test] + fn encryption_error_code_mirrors_the_dpp_error() { + use dpp::consensus::basic::BasicError; + use dpp::consensus::basic::document::InvalidEncryptedPropertyShapeError; + + let error: ConsensusError = BasicError::InvalidEncryptedPropertyShapeError( + InvalidEncryptedPropertyShapeError::new( + "encryptedMessage".to_string(), + "ecdh-secp256k1-aes256-cbc".to_string(), + 47, + 32, + 16, + ), + ) + .into(); + + assert_eq!( + DocumentEncryptionErrorCodeWasm::from_code(error.code()), + Some(DocumentEncryptionErrorCodeWasm::InvalidEncryptedPropertyShape) + ); + assert_eq!( + DocumentEncryptionErrorCodeWasm::InvalidEncryptedPropertyShape as u32, + error.code() + ); + assert_eq!( + ConsensusErrorWasm(error).document_encryption_error_code(), + Some(DocumentEncryptionErrorCodeWasm::InvalidEncryptedPropertyShape) + ); + // A neighbouring code is not claimed. + assert_eq!(DocumentEncryptionErrorCodeWasm::from_code(10419), None); + } + /// The six reference-validation errors, paired with the JS enum variant /// each is advertised to be. /// diff --git a/packages/wasm-dpp2/src/data_contract/document_type_encryption.rs b/packages/wasm-dpp2/src/data_contract/document_type_encryption.rs new file mode 100644 index 00000000000..0d24201d355 --- /dev/null +++ b/packages/wasm-dpp2/src/data_contract/document_type_encryption.rs @@ -0,0 +1,134 @@ +//! `encryptedFor` declarations: how a byte array property's ciphertext was +//! produced, which a contract carries from protocol version 14 onward. +//! +//! `encryptedFor` annotates a byte array property with the recipient, the two +//! key ids and the scheme its bytes were encrypted under, so a wallet reads +//! the recipe from the contract instead of a side channel. Consensus checks +//! the shape of the bytes on every create and replace (code 10420) and +//! nothing more. What this module adds is the ability to *discover* the +//! declarations, "which properties of this document type are encrypted, and +//! with what?", without hand-parsing the contract's raw JSON schema. + +use crate::error::{WasmDppError, WasmDppResult}; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::document_type::EncryptedFor; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use js_sys::{Array, Object, Reflect}; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen(typescript_custom_section)] +const DOCUMENT_PROPERTY_ENCRYPTION_TS: &'static str = r#" +/** + * The scheme an `encryptedFor` property's bytes were produced under. + * + * `ecdh-secp256k1-aes256-cbc` is the scheme of the dashpay contact request: + * the shared key is the libsecp256k1 ECDH of the sender's private key and + * the recipient's public key (SHA256 of the product point's parity byte and + * x coordinate), and the value is a random 16-byte IV followed by the + * plaintext under AES-256-CBC with PKCS7 padding and that IV. A ciphertext + * is therefore at least 32 bytes and always a multiple of 16. + */ +export type EncryptionScheme = 'ecdh-secp256k1-aes256-cbc'; + +/** + * A single `encryptedFor` declaration on a document type. + * + * Mirrors the `encryptedFor` keyword of the v3 document meta-schema, which + * is active from protocol version 14. The field names are the schema + * keyword's own, so what `contract.toJSON()` shows under `encryptedFor` and + * what these accessors return line up key for key. + */ +export type DocumentPropertyEncryption = { + /** + * Dotted path of the declaring byte array property within the document + * type, for example `"encryptedMessage"`, or `"meta.blob"` for a nested + * one. This is the string consensus reports in the `property` field of + * the shape error (code 10420). + */ + path: string; + /** + * Whose keys decrypt the bytes: the dotted path of an identifier property + * of the same document type whose value is the recipient identity's id, + * or `"$ownerId"` for a message the writer encrypts to themself. + */ + recipient: string; + /** + * Dotted path of the integer property of the same document type carrying + * the id of the recipient's key. + */ + recipientKey: string; + /** + * Dotted path of the integer property of the same document type carrying + * the id of the sender's key. + */ + senderKey: string; + /** How the bytes were produced. */ + scheme: EncryptionScheme; +}; +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "Array")] + pub type DocumentPropertyEncryptionArrayJs; + + #[wasm_bindgen(typescript_type = "Map>")] + pub type DocumentPropertyEncryptionMapJs; +} + +/// `Reflect::set` with the collection-getter error convention the `tokens` +/// and `groups` getters on `DataContract` already use. +fn set_field(target: &Object, key: &str, value: &JsValue, path: &str) -> WasmDppResult<()> { + Reflect::set(target, &JsValue::from_str(key), value).map_err(|_| { + WasmDppError::generic(format!( + "unable to serialize the `{key}` field of the encryption declared at '{path}'" + )) + })?; + Ok(()) +} + +/// Build the flat JS object for one declaration. +fn encryption_to_js(path: &str, encrypted_for: &EncryptedFor) -> WasmDppResult { + let object = Object::new(); + set_field(&object, "path", &JsValue::from_str(path), path)?; + set_field( + &object, + "recipient", + &JsValue::from_str(encrypted_for.recipient.as_path()), + path, + )?; + set_field( + &object, + "recipientKey", + &JsValue::from_str(&encrypted_for.recipient_key), + path, + )?; + set_field( + &object, + "senderKey", + &JsValue::from_str(&encrypted_for.sender_key), + path, + )?; + set_field( + &object, + "scheme", + &JsValue::from_str(encrypted_for.scheme.as_str()), + path, + )?; + Ok(object.into()) +} + +/// Collect every `encryptedFor` declaration of one document type, in schema +/// property order, keyed by the dotted path consensus reports. +pub(crate) fn encryptions_for_document_type( + document_type: DocumentTypeRef<'_>, +) -> WasmDppResult { + let encryptions = Array::new(); + + for (path, encrypted_for) in document_type.encrypted_properties() { + encryptions.push(&encryption_to_js(path, encrypted_for)?); + } + + Ok(encryptions) +} diff --git a/packages/wasm-dpp2/src/data_contract/mod.rs b/packages/wasm-dpp2/src/data_contract/mod.rs index a6989437b79..a205ca2023c 100644 --- a/packages/wasm-dpp2/src/data_contract/mod.rs +++ b/packages/wasm-dpp2/src/data_contract/mod.rs @@ -1,6 +1,7 @@ pub mod contract_bounds; pub mod document; pub mod document_type_distinct_from; +pub mod document_type_encryption; pub mod document_type_immutability; pub mod document_type_reference; pub mod document_type_typed_arrays; @@ -12,6 +13,9 @@ pub use document::DocumentWasm; pub use document_type_distinct_from::{ DocumentPropertyDistinctFromArrayJs, DocumentPropertyDistinctFromMapJs, }; +pub use document_type_encryption::{ + DocumentPropertyEncryptionArrayJs, DocumentPropertyEncryptionMapJs, +}; pub use document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, }; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index c197b8dac8e..c9879728340 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -2,6 +2,10 @@ use crate::data_contract::document_type_distinct_from::{ DocumentPropertyDistinctFromArrayJs, DocumentPropertyDistinctFromMapJs, distinct_from_for_document_type, }; +use crate::data_contract::document_type_encryption::{ + DocumentPropertyEncryptionArrayJs, DocumentPropertyEncryptionMapJs, + encryptions_for_document_type, +}; use crate::data_contract::document_type_immutability::{ DocumentTypeImmutablePropertiesJs, DocumentTypeImmutablePropertiesMapJs, immutable_properties_for_document_type, @@ -794,6 +798,55 @@ impl DataContractWasm { Ok(JsValue::from(map).into()) } + /// All `encryptedFor` declarations of one document type, in schema + /// property order: which byte array properties are encrypted, for whom, + /// under which key ids and under which scheme. + /// + /// Returns an empty array when the document type declares none. Throws + /// when the contract has no document type by that name, so "no such + /// type" and "nothing encrypted" stay distinguishable. + /// + /// The keyword is only parsed from protocol version 14 onward. A + /// contract deserialized against an earlier platform version reports + /// none, which is exactly what consensus enforced at that version, while + /// `toJSON()` still shows the raw keyword either way. + #[wasm_bindgen(js_name = "documentTypeEncryptedProperties")] + pub fn document_type_encrypted_properties( + &self, + #[wasm_bindgen(js_name = "documentTypeName")] document_type_name: String, + ) -> WasmDppResult { + let document_type = self + .0 + .document_type_optional_for_name(document_type_name.as_str()) + .ok_or_else(|| { + WasmDppError::invalid_argument(format!( + "document type '{document_type_name}' not found in contract" + )) + })?; + + let encryptions = encryptions_for_document_type(document_type)?; + Ok(JsValue::from(encryptions).into()) + } + + /// Every document type that declares at least one encrypted property, + /// keyed by document type name. + /// + /// Document types with no declarations are omitted, so an empty `Map` + /// means "this contract declares no encrypted property at all". + #[wasm_bindgen(getter = "documentEncryptedProperties")] + pub fn document_encrypted_properties(&self) -> WasmDppResult { + let map = js_sys::Map::new(); + + for (name, document_type) in self.0.document_types() { + let encryptions = encryptions_for_document_type(document_type.as_ref())?; + if encryptions.length() > 0 { + map.set(&JsValue::from_str(name), &encryptions.into()); + } + } + + Ok(JsValue::from(map).into()) + } + /// The `immutable` / `immutableAllowSetting` declarations of one /// document type: `{ immutable: string[], immutableAllowSetting: /// string[] }`, both sorted by property name. diff --git a/packages/wasm-dpp2/tests/unit/DocumentPropertyEncryption.spec.ts b/packages/wasm-dpp2/tests/unit/DocumentPropertyEncryption.spec.ts new file mode 100644 index 00000000000..d5d3e850105 --- /dev/null +++ b/packages/wasm-dpp2/tests/unit/DocumentPropertyEncryption.spec.ts @@ -0,0 +1,239 @@ +/** + * Verifies the `encryptedFor` metadata surface introduced with protocol + * version 14. + * + * A byte array property may declare how its ciphertext was produced: for + * which recipient, under which key ids and under which scheme. Consensus + * checks only the shape of the bytes on every create and replace (code + * 10420). What the JS layer offers is *discovery* (which properties of a + * document type are encrypted, and with what) plus a branchable error code + * for when a write is rejected. + */ +import { expect } from './helpers/chai.ts'; +import { initWasm, wasm } from '../../dist/dpp.compressed.js'; + +let PlatformVersion: typeof wasm.PlatformVersion; + +before(async () => { + await initWasm(); + ({ PlatformVersion } = wasm); +}); + +const ownerId = '11111111111111111111111111111111'; + +const identifier = { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 32, + contentMediaType: 'application/x.dash.dpp.identifier', +}; + +const keyId = { type: 'integer', minimum: 0, maximum: 4294967295 }; + +/** + * A `secret` whose message is encrypted to a recipient, a `note` whose + * body is encrypted to its own writer, next to a `plain` type declaring + * nothing. + */ +const schemas = { + secret: { + type: 'object', + properties: { + recipientId: { ...identifier, position: 0 }, + recipientKeyId: { ...keyId, position: 1 }, + senderKeyId: { ...keyId, position: 2 }, + encryptedMessage: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 1040, + position: 3, + encryptedFor: { + recipient: 'recipientId', + recipientKey: 'recipientKeyId', + senderKey: 'senderKeyId', + scheme: 'ecdh-secp256k1-aes256-cbc', + }, + }, + }, + required: ['recipientId', 'recipientKeyId', 'senderKeyId', 'encryptedMessage'], + additionalProperties: false, + }, + note: { + type: 'object', + properties: { + keyId: { ...keyId, position: 0 }, + body: { + type: 'array', + byteArray: true, + minItems: 32, + maxItems: 4096, + position: 1, + encryptedFor: { + recipient: '$ownerId', + recipientKey: 'keyId', + senderKey: 'keyId', + scheme: 'ecdh-secp256k1-aes256-cbc', + }, + }, + }, + additionalProperties: false, + }, + plain: { + type: 'object', + properties: { + message: { type: 'string', position: 0, maxLength: 64 }, + }, + additionalProperties: false, + }, +}; + +function buildContract(platformVersion: number, fullValidation = true) { + return new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas, + definitions: null, + fullValidation, + platformVersion: new PlatformVersion(platformVersion), + }); +} + +type Encryption = { + path: string; + recipient: string; + recipientKey: string; + senderKey: string; + scheme: string; +}; + +describe('DataContract: encrypted properties (v14)', () => { + describe('documentTypeEncryptedProperties()', () => { + it('should report the declaration with the schema keyword names', () => { + const contract = buildContract(14); + + expect(contract.documentTypeEncryptedProperties('secret')).to.deep.equal([ + { + path: 'encryptedMessage', + recipient: 'recipientId', + recipientKey: 'recipientKeyId', + senderKey: 'senderKeyId', + scheme: 'ecdh-secp256k1-aes256-cbc', + }, + ]); + }); + + it('should spell an owner recipient as $ownerId', () => { + const contract = buildContract(14); + + const [body] = contract.documentTypeEncryptedProperties('note') as Encryption[]; + expect(body.path).to.equal('body'); + expect(body.recipient).to.equal('$ownerId'); + }); + + it('should return an empty array for a document type declaring none', () => { + const contract = buildContract(14); + + expect(contract.documentTypeEncryptedProperties('plain')).to.deep.equal([]); + }); + + /** + * An empty array would conflate "no such type" with "nothing encrypted", + * which is a difference a caller acting on the result needs. + */ + it('should throw for an unknown document type', () => { + const contract = buildContract(14); + + expect(() => contract.documentTypeEncryptedProperties('doesNotExist')).to.throw(/not found/); + }); + + /** + * The keyword is only parsed from protocol version 14 onward. A contract + * deserialized against an earlier version reports nothing encrypted even + * though its raw schema still carries it. + */ + it('should report nothing on a pre-v14 contract, while the raw schema keeps the keyword', () => { + const contract = buildContract(13, false); + + expect(contract.documentTypeEncryptedProperties('secret')).to.deep.equal([]); + + const rawSchemas = contract.schemas as Record< + string, + { properties: Record }> } + >; + expect(rawSchemas.secret.properties.encryptedMessage.encryptedFor).to.deep.equal({ + recipient: 'recipientId', + recipientKey: 'recipientKeyId', + senderKey: 'senderKeyId', + scheme: 'ecdh-secp256k1-aes256-cbc', + }); + }); + + /** + * The declaration is consensus-validated at registration: a recipient + * that is not an identifier property, a key path naming nothing, or an + * unknown scheme is a contract error, not something the accessor has to + * guard against. + */ + it('should refuse a contract whose recipient key names no property', () => { + const build = () => new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: { + secret: { + ...schemas.secret, + properties: { + ...schemas.secret.properties, + encryptedMessage: { + ...schemas.secret.properties.encryptedMessage, + encryptedFor: { + ...schemas.secret.properties.encryptedMessage.encryptedFor, + recipientKey: 'nowhere', + }, + }, + }, + }, + }, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + + expect(build).to.throw(/not a property of the document type/); + }); + }); + + describe('documentEncryptedProperties', () => { + it('should key declarations by document type and omit types declaring none', () => { + const contract = buildContract(14); + const map = contract.documentEncryptedProperties as Map; + + expect([...map.keys()].sort()).to.deep.equal(['note', 'secret']); + expect(map.get('secret')).to.deep.equal(contract.documentTypeEncryptedProperties('secret')); + }); + + it('should be empty for a contract encrypting nothing at all', () => { + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(2), + schemas: { plain: schemas.plain }, + definitions: null, + fullValidation: true, + platformVersion: new PlatformVersion(14), + }); + + const map = contract.documentEncryptedProperties as Map; + expect(map.size).to.equal(0); + }); + }); + + describe('DocumentEncryptionErrorCode', () => { + it('should expose the shape error code both ways', () => { + const { DocumentEncryptionErrorCode } = wasm; + + expect(DocumentEncryptionErrorCode.InvalidEncryptedPropertyShape).to.equal(10420); + expect(DocumentEncryptionErrorCode[10420]).to.equal('InvalidEncryptedPropertyShape'); + }); + }); +});