diff --git a/rust/signed_doc/Cargo.toml b/rust/signed_doc/Cargo.toml index facde473f9..8f8780528f 100644 --- a/rust/signed_doc/Cargo.toml +++ b/rust/signed_doc/Cargo.toml @@ -11,7 +11,6 @@ license.workspace = true workspace = true [dependencies] -rbac-registration = { version = "0.0.4", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250220-00" } catalyst-types = { version = "0.0.3", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250220-00" } anyhow = "1.0.95" serde = { version = "1.0.217", features = ["derive"] } @@ -19,16 +18,19 @@ serde_json = "1.0.134" coset = "0.3.8" minicbor = { version = "0.25.1", features = ["half"] } brotli = "7.0.0" -ed25519-dalek = { version = "2.1.1", features = ["pem", "rand_core"] } +ed25519-dalek = { version = "2.1.1" } hex = "0.4.3" strum = { version = "0.26.3", features = ["derive"] } -clap = { version = "4.5.23", features = ["derive", "env"] } - +clap = { version = "4.5.23", features = ["derive", "env"] } +jsonschema = "0.28.3" +jsonpath-rust = "0.7.5" +futures = "0.3.31" [dev-dependencies] base64-url = "3.0.0" rand = "0.8.5" - +tokio = { version = "1.42.0", features = [ "macros" ] } +ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } [[bin]] name = "signed-docs" diff --git a/rust/signed_doc/examples/mk_signed_doc.rs b/rust/signed_doc/examples/mk_signed_doc.rs index 8a96bd53f2..8af76e5b63 100644 --- a/rust/signed_doc/examples/mk_signed_doc.rs +++ b/rust/signed_doc/examples/mk_signed_doc.rs @@ -8,9 +8,10 @@ use std::{ path::PathBuf, }; -use catalyst_signed_doc::{Builder, CatalystSignedDocument, IdUri, Metadata, SimplePublicKeyType}; +use anyhow::Context; +use catalyst_signed_doc::{Builder, CatalystSignedDocument, IdUri, Metadata}; use clap::Parser; -use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey}; +use ed25519_dalek::pkcs8::DecodePrivateKey; fn main() { if let Err(err) = Cli::parse().exec() { @@ -51,16 +52,6 @@ enum Cli { /// Hex-formatted COSE SIGN Bytes cose_sign_hex: String, }, - /// Validates a signature by Key ID and verifying key - Verify { - /// Path to the formed (could be empty, without any signatures) COSE document - /// This exact file would be modified and new signature would be added - path: PathBuf, - /// Path to the verifying key in PEM format - pk: PathBuf, - /// Signer kid - kid: IdUri, - }, } impl Cli { @@ -87,7 +78,7 @@ impl Cli { .map_err(|e| anyhow::anyhow!("Failed to load SK FILE: {e}"))?; let cose_bytes = read_bytes_from_file(&doc)?; let signed_doc = signed_doc_from_bytes(cose_bytes.as_slice())?; - let builder = signed_doc.into_builder(); + let builder = signed_doc.into_builder()?; let new_signed_doc = builder.add_signature(sk.to_bytes(), kid)?.build()?; save_signed_doc(new_signed_doc, &doc)?; }, @@ -99,22 +90,6 @@ impl Cli { let cose_bytes = hex::decode(&cose_sign_hex)?; inspect_signed_doc(&cose_bytes)?; }, - Self::Verify { path, pk, kid } => { - let pk = load_public_key_from_file(&pk) - .map_err(|e| anyhow::anyhow!("Failed to load PK FILE {pk:?}: {e}"))?; - let cose_bytes = read_bytes_from_file(&path)?; - let signed_doc = signed_doc_from_bytes(cose_bytes.as_slice())?; - signed_doc - .verify(|k| { - if k.to_string() == kid.to_string() { - SimplePublicKeyType::Ed25519(pk) - } else { - SimplePublicKeyType::Undefined - } - }) - .map_err(|e| anyhow::anyhow!("Catalyst Document Verification failed: {e}"))?; - println!("Catalyst Signed Document is Verified."); - }, } println!("Done"); Ok(()) @@ -142,15 +117,13 @@ fn inspect_signed_doc(cose_bytes: &[u8]) -> anyhow::Result<()> { fn save_signed_doc(signed_doc: CatalystSignedDocument, path: &PathBuf) -> anyhow::Result<()> { let mut bytes: Vec = Vec::new(); - minicbor::encode(signed_doc, &mut bytes) - .map_err(|e| anyhow::anyhow!("Failed to encode document: {e}"))?; + minicbor::encode(signed_doc, &mut bytes).context("Failed to encode document")?; write_bytes_to_file(&bytes, path) } fn signed_doc_from_bytes(cose_bytes: &[u8]) -> anyhow::Result { - CatalystSignedDocument::try_from(cose_bytes) - .map_err(|e| anyhow::anyhow!("Invalid Catalyst Document: {e}")) + minicbor::decode(cose_bytes).context("Invalid Catalyst Document") } fn load_json_from_file(path: &PathBuf) -> anyhow::Result @@ -163,7 +136,7 @@ where T: for<'de> serde::Deserialize<'de> { fn write_bytes_to_file(bytes: &[u8], output: &PathBuf) -> anyhow::Result<()> { File::create(output)? .write_all(bytes) - .map_err(|e| anyhow::anyhow!("Failed to write to file {output:?}: {e}")) + .context(format!("Failed to write to file {output:?}")) } fn load_secret_key_from_file(sk_path: &PathBuf) -> anyhow::Result { @@ -171,9 +144,3 @@ fn load_secret_key_from_file(sk_path: &PathBuf) -> anyhow::Result anyhow::Result { - let pk_str = read_to_string(pk_path)?; - let pk = ed25519_dalek::VerifyingKey::from_public_key_pem(&pk_str)?; - Ok(pk) -} diff --git a/rust/signed_doc/src/builder.rs b/rust/signed_doc/src/builder.rs index 1640c8a77f..ed1f1b822f 100644 --- a/rust/signed_doc/src/builder.rs +++ b/rust/signed_doc/src/builder.rs @@ -1,8 +1,11 @@ //! Catalyst Signed Document Builder. -use catalyst_types::id_uri::IdUri; +use catalyst_types::{id_uri::IdUri, problem_report::ProblemReport}; use ed25519_dalek::{ed25519::signature::Signer, SecretKey}; -use crate::{CatalystSignedDocument, Content, InnerCatalystSignedDocument, Metadata, Signatures}; +use crate::{ + CatalystSignedDocument, Content, InnerCatalystSignedDocument, Metadata, Signatures, + PROBLEM_REPORT_CTX, +}; /// Catalyst Signed Document Builder. #[derive(Debug, Default, Clone)] @@ -29,6 +32,15 @@ impl Builder { self } + /// Set document metadata in JSON format + /// + /// # Errors + /// - Fails if it is invalid metadata JSON object. + pub fn with_json_metadata(mut self, json: serde_json::Value) -> anyhow::Result { + self.metadata = Some(serde_json::from_value(json)?); + Ok(self) + } + /// Set decoded (original) document content bytes #[must_use] pub fn with_decoded_content(mut self, content: Vec) -> Self { @@ -68,7 +80,7 @@ impl Builder { let sk = ed25519_dalek::SigningKey::from_bytes(&sk); let protected_header = coset::HeaderBuilder::new() .key_id(kid.to_string().into_bytes()) - .algorithm(metadata.algorithm().into()); + .algorithm(metadata.algorithm()?.into()); let mut signature = coset::CoseSignatureBuilder::new() .protected(protected_header.build()) .build(); @@ -94,17 +106,14 @@ impl Builder { anyhow::bail!("Failed to build Catalyst Signed Document, missing document's content"); }; let signatures = self.signatures; + let content = Content::from_decoded(content, metadata.content_type()?)?; - let content = Content::from_decoded( - content, - metadata.content_type(), - metadata.content_encoding(), - )?; - + let empty_report = ProblemReport::new(PROBLEM_REPORT_CTX); Ok(InnerCatalystSignedDocument { metadata, content, signatures, + report: empty_report, } .into()) } diff --git a/rust/signed_doc/src/content.rs b/rust/signed_doc/src/content.rs index 92c644e158..b1abfbcbc9 100644 --- a/rust/signed_doc/src/content.rs +++ b/rust/signed_doc/src/content.rs @@ -1,40 +1,50 @@ //! Catalyst Signed Document Content Payload +use catalyst_types::problem_report::ProblemReport; + use crate::metadata::{ContentEncoding, ContentType}; /// Decompressed Document Content type bytes. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub struct Content { - /// Content data bytes - data: Vec, - /// Content type - content_type: ContentType, - /// Content encoding - content_encoding: Option, + /// Original Decompressed Document's data bytes + data: Option>, } impl Content { /// Creates a new `Content` value, from the encoded data. /// verifies a Document's content, that it is correctly encoded and it corresponds and /// parsed to the specified type - /// - /// # Errors - /// Returns an error if content is not correctly encoded pub(crate) fn from_encoded( - mut data: Vec, content_type: ContentType, content_encoding: Option, - ) -> anyhow::Result { - if let Some(encoding) = content_encoding { - data = encoding - .decode(&data) - .map_err(|e| anyhow::anyhow!("Failed to decode {encoding} content: {e}"))?; + mut data: Vec, content_type: Option, + content_encoding: Option, report: &ProblemReport, + ) -> Self { + if let Some(content_encoding) = content_encoding { + if let Ok(decoded_data) = content_encoding.decode(&data) { + data = decoded_data; + } else { + report.invalid_value( + "payload", + &hex::encode(&data), + &format!("Invalid Document content, should {content_encoding} encodable"), + "Invalid Document content type.", + ); + return Self::default(); + } + } + if let Some(content_type) = content_type { + if content_type.validate(&data).is_err() { + report.invalid_value( + "payload", + &hex::encode(&data), + &format!("Invalid Document content type, should {content_type} encodable"), + "Invalid Document content type.", + ); + return Self::default(); + } } - content_type.validate(&data)?; - Ok(Self { - data, - content_type, - content_encoding, - }) + Self { data: Some(data) } } /// Creates a new `Content` value, from the decoded (original) data. @@ -42,52 +52,41 @@ impl Content { /// /// # Errors /// Returns an error if content is not correctly encoded - pub(crate) fn from_decoded( - data: Vec, content_type: ContentType, content_encoding: Option, - ) -> anyhow::Result { + pub(crate) fn from_decoded(data: Vec, content_type: ContentType) -> anyhow::Result { content_type.validate(&data)?; - Ok(Self { - data, - content_type, - content_encoding, - }) - } - - /// Return `true` if Document's content type is Json - #[must_use] - pub fn is_json(&self) -> bool { - matches!(self.content_type, ContentType::Json) + Ok(Self { data: Some(data) }) } - /// Return `true` if Document's content type is Json - #[must_use] - pub fn is_cbor(&self) -> bool { - matches!(self.content_type, ContentType::Cbor) - } - - /// Return an decoded (original) content bytes, - /// by the corresponding `content_encoding` provided field. - #[must_use] - pub fn decoded_bytes(&self) -> &[u8] { - &self.data + /// Return an decoded (original) content bytes. + /// + /// # Errors + /// - Missing Document content + pub fn decoded_bytes(&self) -> anyhow::Result<&[u8]> { + self.data + .as_deref() + .ok_or(anyhow::anyhow!("Missing Document content")) } /// Return an encoded content bytes, - /// by the corresponding `content_encoding` provided field - pub(crate) fn encoded_bytes(&self) -> anyhow::Result> { - if let Some(encoding) = self.content_encoding { - let data = encoding - .encode(&self.data) - .map_err(|e| anyhow::anyhow!("Failed to encode {encoding} content: {e}"))?; - Ok(data) - } else { - Ok(self.data.clone()) - } + /// by the provided `content_encoding` provided field. + /// + /// # Errors + /// - Missing Document content + /// - Failed to encode content. + pub(crate) fn encoded_bytes( + &self, content_encoding: ContentEncoding, + ) -> anyhow::Result> { + let content = self.decoded_bytes()?; + let data = content_encoding + .encode(content) + .map_err(|e| anyhow::anyhow!("Failed to encode {content_encoding} content: {e}"))?; + Ok(data) } - /// Return content byte size + /// Return content byte size. + /// If content is empty returns `0`. #[must_use] pub fn size(&self) -> usize { - self.data.len() + self.data.as_ref().map(Vec::len).unwrap_or_default() } } diff --git a/rust/signed_doc/src/doc_types/mod.rs b/rust/signed_doc/src/doc_types/mod.rs index b971e01414..683e36db2f 100644 --- a/rust/signed_doc/src/doc_types/mod.rs +++ b/rust/signed_doc/src/doc_types/mod.rs @@ -1,112 +1,53 @@ //! An implementation of different defined document types //! -mod proposal_document; - -use catalyst_types::uuid::{Uuid, UuidV4}; -pub use proposal_document::{ProposalDocument, PROPOSAL_DOCUMENT_UUID_TYPE}; - -/// Represents different types of documents. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DocumentType { - /// A proposal document containing proposal details. - ProposalDocument, - /// A template for proposal documents, defining the expected structure. - ProposalTemplate, - /// A document representing a comment on a proposal. - CommentDocument, - /// A template for comment documents, defining the expected structure. - CommentTemplate, - /// A review document containing feedback on a proposal. - ReviewDocument, - /// A template for review documents, defining the expected structure. - ReviewTemplate, - /// A document defining parameters for a specific category. - CategoryParametersDocument, - /// A template for category parameter documents, defining the expected structure. - CategoryParametersTemplate, - /// A document containing parameters for a specific campaign. - CampaignParametersDocument, - /// A template for campaign parameter documents, defining the expected structure. - CampaignParametersTemplate, - /// A document containing brand-related parameters. - BrandParametersDocument, - /// A template for brand parameter documents, defining the expected structure. - BrandParametersTemplate, - /// A document representing an action related to a proposal. - ProposalActionDocument, - /// A public voting transaction version 2. - PublicVoteTxV2, - /// A private voting transaction version 2. - PrivateVoteTxV2, - /// A block in the immutable ledger. - ImmutableLedgerBlock, -} +use catalyst_types::uuid::Uuid; +/// Proposal document `UuidV4` type. +pub const PROPOSAL_DOCUMENT_UUID_TYPE: Uuid = + Uuid::from_u128(0x7808_D2BA_D511_40AF_84E8_C0D1_625F_DFDC); /// Proposal template `UuidV4` type. -const PROPOSAL_TEMPLATE_UUID_TYPE: Uuid = +pub const PROPOSAL_TEMPLATE_UUID_TYPE: Uuid = Uuid::from_u128(0x0CE8_AB38_9258_4FBC_A62E_7FAA_6E58_318F); /// Comment document `UuidV4` type. -const COMMENT_DOCUMENT_UUID_TYPE: Uuid = Uuid::from_u128(0xB679_DED3_0E7C_41BA_89F8_DA62_A178_98EA); +pub const COMMENT_DOCUMENT_UUID_TYPE: Uuid = + Uuid::from_u128(0xB679_DED3_0E7C_41BA_89F8_DA62_A178_98EA); /// Comment template `UuidV4` type. -const COMMENT_TEMPLATE_UUID_TYPE: Uuid = Uuid::from_u128(0x0B84_24D4_EBFD_46E3_9577_1775_A69D_290C); +pub const COMMENT_TEMPLATE_UUID_TYPE: Uuid = + Uuid::from_u128(0x0B84_24D4_EBFD_46E3_9577_1775_A69D_290C); /// Review document `UuidV4` type. -const REVIEW_DOCUMENT_UUID_TYPE: Uuid = Uuid::from_u128(0xE4CA_F5F0_098B_45FD_94F3_0702_A457_3DB5); +pub const REVIEW_DOCUMENT_UUID_TYPE: Uuid = + Uuid::from_u128(0xE4CA_F5F0_098B_45FD_94F3_0702_A457_3DB5); /// Review template `UuidV4` type. -const REVIEW_TEMPLATE_UUID_TYPE: Uuid = Uuid::from_u128(0xEBE5_D0BF_5D86_4577_AF4D_008F_DDBE_2EDC); -/// Category parameters document `UuidV4` type. -const CATEGORY_PARAMETERS_DOCUMENT_UUID_TYPE: Uuid = +pub const REVIEW_TEMPLATE_UUID_TYPE: Uuid = + Uuid::from_u128(0xEBE5_D0BF_5D86_4577_AF4D_008F_DDBE_2EDC); +/// Category document `UuidV4` type. +pub const CATEGORY_DOCUMENT_UUID_TYPE: Uuid = Uuid::from_u128(0x48C2_0109_362A_4D32_9BBA_E0A9_CF8B_45BE); -/// Category parameters template `UuidV4` type. -const CATEGORY_PARAMETERS_TEMPLATE_UUID_TYPE: Uuid = +/// Category template `UuidV4` type. +pub const CATEGORY_TEMPLATE_UUID_TYPE: Uuid = Uuid::from_u128(0x65B1_E8B0_51F1_46A5_9970_72CD_F268_84BE); /// Campaign parameters document `UuidV4` type. -const CAMPAIGN_PARAMETERS_DOCUMENT_UUID_TYPE: Uuid = +pub const CAMPAIGN_DOCUMENT_UUID_TYPE: Uuid = Uuid::from_u128(0x0110_EA96_A555_47CE_8408_36EF_E6ED_6F7C); /// Campaign parameters template `UuidV4` type. -const CAMPAIGN_PARAMETERS_TEMPLATE_UUID_TYPE: Uuid = +pub const CAMPAIGN_TEMPLATE_UUID_TYPE: Uuid = Uuid::from_u128(0x7E8F_5FA2_44CE_49C8_BFD5_02AF_42C1_79A3); /// Brand parameters document `UuidV4` type. -const BRAND_PARAMETERS_DOCUMENT_UUID_TYPE: Uuid = +pub const BRAND_DOCUMENT_UUID_TYPE: Uuid = Uuid::from_u128(0x3E48_08CC_C86E_467B_9702_D60B_AA9D_1FCA); /// Brand parameters template `UuidV4` type. -const BRAND_PARAMETERS_TEMPLATE_UUID_TYPE: Uuid = +pub const BRAND_TEMPLATE_UUID_TYPE: Uuid = Uuid::from_u128(0xFD3C_1735_80B1_4EEA_8D63_5F43_6D97_EA31); /// Proposal action document `UuidV4` type. -const PROPOSAL_ACTION_DOCUMENT_UUID_TYPE: Uuid = +pub const PROPOSAL_ACTION_DOCUMENT_UUID_TYPE: Uuid = Uuid::from_u128(0x5E60_E623_AD02_4A1B_A1AC_406D_B978_EE48); /// Public vote transaction v2 `UuidV4` type. -const PUBLIC_VOTE_TX_V2_UUID_TYPE: Uuid = +pub const PUBLIC_VOTE_TX_V2_UUID_TYPE: Uuid = Uuid::from_u128(0x8DE5_586C_E998_4B95_8742_7BE3_C859_2803); /// Private vote transaction v2 `UuidV4` type. -const PRIVATE_VOTE_TX_V2_UUID_TYPE: Uuid = +pub const PRIVATE_VOTE_TX_V2_UUID_TYPE: Uuid = Uuid::from_u128(0xE78E_E18D_F380_44C1_A852_80AA_6ECB_07FE); /// Immutable ledger block `UuidV4` type. -const IMMUTABLE_LEDGER_BLOCK_UUID_TYPE: Uuid = +pub const IMMUTABLE_LEDGER_BLOCK_UUID_TYPE: Uuid = Uuid::from_u128(0xD9E7_E6CE_2401_4D7D_9492_F4F7_C642_41C3); - -impl TryFrom for DocumentType { - type Error = anyhow::Error; - - fn try_from(uuid: UuidV4) -> Result { - match uuid.uuid() { - PROPOSAL_DOCUMENT_UUID_TYPE => Ok(DocumentType::ProposalDocument), - PROPOSAL_TEMPLATE_UUID_TYPE => Ok(DocumentType::ProposalTemplate), - COMMENT_DOCUMENT_UUID_TYPE => Ok(DocumentType::CommentDocument), - COMMENT_TEMPLATE_UUID_TYPE => Ok(DocumentType::CommentTemplate), - REVIEW_DOCUMENT_UUID_TYPE => Ok(DocumentType::ReviewDocument), - REVIEW_TEMPLATE_UUID_TYPE => Ok(DocumentType::ReviewTemplate), - CATEGORY_PARAMETERS_DOCUMENT_UUID_TYPE => Ok(DocumentType::CategoryParametersDocument), - CATEGORY_PARAMETERS_TEMPLATE_UUID_TYPE => Ok(DocumentType::CategoryParametersTemplate), - CAMPAIGN_PARAMETERS_DOCUMENT_UUID_TYPE => Ok(DocumentType::CampaignParametersDocument), - CAMPAIGN_PARAMETERS_TEMPLATE_UUID_TYPE => Ok(DocumentType::CampaignParametersTemplate), - BRAND_PARAMETERS_DOCUMENT_UUID_TYPE => Ok(DocumentType::BrandParametersDocument), - BRAND_PARAMETERS_TEMPLATE_UUID_TYPE => Ok(DocumentType::BrandParametersTemplate), - PROPOSAL_ACTION_DOCUMENT_UUID_TYPE => Ok(DocumentType::ProposalActionDocument), - PUBLIC_VOTE_TX_V2_UUID_TYPE => Ok(DocumentType::PublicVoteTxV2), - PRIVATE_VOTE_TX_V2_UUID_TYPE => Ok(DocumentType::PrivateVoteTxV2), - IMMUTABLE_LEDGER_BLOCK_UUID_TYPE => Ok(DocumentType::ImmutableLedgerBlock), - _ => anyhow::bail!("Unsupported document type"), - } - } -} diff --git a/rust/signed_doc/src/doc_types/proposal_document.rs b/rust/signed_doc/src/doc_types/proposal_document.rs deleted file mode 100644 index 4756c956fa..0000000000 --- a/rust/signed_doc/src/doc_types/proposal_document.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! Proposal Document object implementation -//! - -use catalyst_types::{problem_report::ProblemReport, uuid::Uuid}; - -use crate::{error::CatalystSignedDocError, CatalystSignedDocument}; - -/// Proposal document `UuidV4` type. -pub const PROPOSAL_DOCUMENT_UUID_TYPE: Uuid = - Uuid::from_u128(0x7808_D2BA_D511_40AF_84E8_C0D1_625F_DFDC); - -/// Proposal Document struct -pub struct ProposalDocument { - /// Proposal document content data - /// TODO: change it to `serde_json::Value` type - #[allow(dead_code)] - content: Vec, -} - -impl ProposalDocument { - /// Try to build `ProposalDocument` from `CatalystSignedDoc` doing all necessary - /// stateless verifications, - #[allow(dead_code)] - pub(crate) fn from_signed_doc( - doc: &CatalystSignedDocument, error_report: &ProblemReport, - ) -> anyhow::Result { - /// Context for error messages. - const CONTEXT: &str = "Catalyst Signed Document to Proposal Document"; - let mut failed = false; - - if doc.doc_type().uuid() != PROPOSAL_DOCUMENT_UUID_TYPE { - error_report.invalid_value( - "`type`", - &doc.doc_type().to_string(), - &format!("Proposal Document type UUID value is {PROPOSAL_DOCUMENT_UUID_TYPE}"), - CONTEXT, - ); - failed = true; - } - - // TODO add other validation - - if failed { - anyhow::bail!("Failed to build `ProposalDocument` from `CatalystSignedDoc`"); - } - - let content = doc.doc_content().decoded_bytes().to_vec(); - Ok(Self { content }) - } - - /// A comprehensive validation of the `ProposalDocument` content. - #[allow(clippy::unused_self)] - pub(crate) fn validate_with_report(&self, _doc_getter: F, _error_report: &ProblemReport) - where F: FnMut() -> Option { - // TODO: implement the rest of the validation - } -} - -impl TryFrom for ProposalDocument { - type Error = CatalystSignedDocError; - - fn try_from(doc: CatalystSignedDocument) -> Result { - let error_report = ProblemReport::new("Proposal Document"); - let res = Self::from_signed_doc(&doc, &error_report) - .map_err(|e| CatalystSignedDocError::new(error_report, e))?; - Ok(res) - } -} diff --git a/rust/signed_doc/src/error.rs b/rust/signed_doc/src/error.rs deleted file mode 100644 index 5bea115736..0000000000 --- a/rust/signed_doc/src/error.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Catalyst Signed Document Error - -use std::fmt; - -use catalyst_types::problem_report::ProblemReport; - -/// Catalyst Signed Document Error -#[allow(clippy::module_name_repetitions)] -#[derive(Debug)] -pub struct CatalystSignedDocError { - /// List of errors during processing. - report: ProblemReport, - /// Actual error. - error: anyhow::Error, -} - -impl CatalystSignedDocError { - /// Create a new `CatalystSignedDocError`. - #[must_use] - pub fn new(report: ProblemReport, error: anyhow::Error) -> Self { - Self { report, error } - } - - /// Get the error report. - #[must_use] - pub fn report(&self) -> &ProblemReport { - &self.report - } - - /// Get the actual error. - #[must_use] - pub fn error(&self) -> &anyhow::Error { - &self.error - } -} - -impl fmt::Display for CatalystSignedDocError { - fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { - let report_json = serde_json::to_string(&self.report) - .unwrap_or_else(|_| String::from("Failed to serialize ProblemReport")); - - write!( - fmt, - "CatalystSignedDocError {{ error: {}, report: {} }}", - self.error, report_json - ) - } -} diff --git a/rust/signed_doc/src/lib.rs b/rust/signed_doc/src/lib.rs index 7c40539296..d78508ae53 100644 --- a/rust/signed_doc/src/lib.rs +++ b/rust/signed_doc/src/lib.rs @@ -3,10 +3,9 @@ mod builder; mod content; pub mod doc_types; -pub mod error; mod metadata; +pub mod providers; mod signature; -mod utils; pub mod validator; use std::{ @@ -20,12 +19,15 @@ use catalyst_types::problem_report::ProblemReport; pub use catalyst_types::uuid::{Uuid, UuidV4, UuidV7}; pub use content::Content; use coset::{CborSerializable, Header}; -use error::CatalystSignedDocError; -pub use metadata::{DocumentRef, ExtraFields, Metadata}; -pub use minicbor::{decode, encode, Decode, Decoder, Encode, Encoder}; -pub use rbac_registration::cardano::cip509::SimplePublicKeyType; +pub use metadata::{ + Algorithm, ContentEncoding, ContentType, DocumentRef, ExtraFields, Metadata, Section, +}; +use minicbor::{decode, encode, Decode, Decoder, Encode}; +use providers::VerifyingKeyProvider; pub use signature::{IdUri, Signatures}; -use utils::context::DecodeSignDocCtx; + +/// A problem report content string +const PROBLEM_REPORT_CTX: &str = "Catalyst Signed Document"; /// Inner type that holds the Catalyst Signed Document with parsing errors. #[derive(Debug, Clone)] @@ -36,12 +38,16 @@ struct InnerCatalystSignedDocument { content: Content, /// Signatures signatures: Signatures, + /// A comprehensive problem report, which could include a decoding errors along with + /// the other validation errors + report: ProblemReport, } /// Keep all the contents private. /// Better even to use a structure like this. Wrapping in an Arc means we don't have to /// manage the Arc anywhere else. These are likely to be large, best to have the Arc be /// non-optional. +#[derive(Clone)] pub struct CatalystSignedDocument { /// Catalyst Signed Document metadata, raw doc, with content errors. inner: Arc, @@ -75,20 +81,26 @@ impl CatalystSignedDocument { // A bunch of getters to access the contents, or reason through the document, such as. /// Return Document Type `UUIDv4`. - #[must_use] - pub fn doc_type(&self) -> UuidV4 { + /// + /// # Errors + /// - Missing 'type' field. + pub fn doc_type(&self) -> anyhow::Result { self.inner.metadata.doc_type() } /// Return Document ID `UUIDv7`. - #[must_use] - pub fn doc_id(&self) -> UuidV7 { + /// + /// # Errors + /// - Missing 'id' field. + pub fn doc_id(&self) -> anyhow::Result { self.inner.metadata.doc_id() } /// Return Document Version `UUIDv7`. - #[must_use] - pub fn doc_ver(&self) -> UuidV7 { + /// + /// # Errors + /// - Missing 'ver' field. + pub fn doc_ver(&self) -> anyhow::Result { self.inner.metadata.doc_ver() } @@ -98,6 +110,20 @@ impl CatalystSignedDocument { &self.inner.content } + /// Return document `ContentType`. + /// + /// # Errors + /// - Missing 'content-type' field. + pub fn doc_content_type(&self) -> anyhow::Result { + self.inner.metadata.content_type() + } + + /// Return document `ContentEncoding`. + #[must_use] + pub fn doc_content_encoding(&self) -> Option { + self.inner.metadata.content_encoding() + } + /// Return document metadata content. #[must_use] pub fn doc_meta(&self) -> &ExtraFields { @@ -122,113 +148,119 @@ impl CatalystSignedDocument { self.inner.signatures.authors() } + /// Returns a collected problem report for the document. + /// It accumulates all kind of errors, collected during the decoding, type based + /// validation and signature verification. + /// + /// This is method is only for the public API usage, do not use it internally inside + /// this crate. + #[must_use] + pub fn problem_report(&self) -> ProblemReport { + self.report().clone() + } + + /// Returns an internal problem report + #[must_use] + pub(crate) fn report(&self) -> &ProblemReport { + &self.inner.report + } + /// Verify document signatures. + /// Return true if all signatures are valid, otherwise return false. /// - /// # Errors /// - /// Returns a report of verification failures and the source error. - #[allow(clippy::indexing_slicing)] - pub fn verify

(&self, pk_getter: P) -> Result<(), CatalystSignedDocError> - where P: Fn(&IdUri) -> SimplePublicKeyType { - let error_report = ProblemReport::new("Catalyst Signed Document Verification"); - - match self.as_cose_sign() { - Ok(cose_sign) => { - let signatures = self.signatures().cose_signatures(); - for (idx, kid) in self.kids().iter().enumerate() { - match pk_getter(kid) { - SimplePublicKeyType::Ed25519(pk) => { - let signature = &signatures[idx]; - let tbs_data = cose_sign.tbs_data(&[], signature); - match signature.signature.as_slice().try_into() { - Ok(signature_bytes) => { - let signature = - ed25519_dalek::Signature::from_bytes(signature_bytes); - if let Err(e) = pk.verify_strict(&tbs_data, &signature) { - error_report.functional_validation( - &format!( - "Verification failed for signature with Key ID {kid}: {e}" - ), - "During signature validation with verifying key", - ); - } - }, - Err(_) => { - error_report.invalid_value( - "cose signature", - &format!("{}", signature.signature.len()), - &format!("must be {}", ed25519_dalek::Signature::BYTE_SIZE), - "During encoding cose signature to bytes", - ); - }, - } - }, - SimplePublicKeyType::Deleted => { - error_report.other( - &format!("Public key for {kid} has been deleted."), - "During public key extraction", - ); - }, - SimplePublicKeyType::Undefined => { - error_report.other( - &format!("Public key for {kid} is undefined."), - "During public key extraction", + /// # Errors + /// If `provider` returns error, fails fast throwing that error. + pub async fn verify(&self, provider: &impl VerifyingKeyProvider) -> anyhow::Result { + if self.report().is_problematic() { + return Ok(false); + } + + let Ok(cose_sign) = self.as_cose_sign() else { + self.report().other( + "Cannot build a COSE sign object", + "During encoding signed document as COSE SIGN", + ); + return Ok(false); + }; + + for (signature, kid) in self.signatures().cose_signatures_with_kids() { + if let Some(pk) = provider.try_get_key(kid).await? { + let tbs_data = cose_sign.tbs_data(&[], signature); + match signature.signature.as_slice().try_into() { + Ok(signature_bytes) => { + let signature = ed25519_dalek::Signature::from_bytes(signature_bytes); + if let Err(e) = pk.verify_strict(&tbs_data, &signature) { + self.report().functional_validation( + &format!( + "Verification failed for signature with Key ID {kid}: {e}" + ), + "During signature validation with verifying key", ); - }, - } + } + }, + Err(_) => { + self.report().invalid_value( + "cose signature", + &format!("{}", signature.signature.len()), + &format!("must be {}", ed25519_dalek::Signature::BYTE_SIZE), + "During encoding cose signature to bytes", + ); + }, } - }, - Err(e) => { - error_report.other( - &format!("{e}"), - "During encoding signed document as COSE SIGN", + } else { + self.report().other( + &format!("Missing public key for {kid}."), + "During public key extraction", ); - }, - } - - if error_report.is_problematic() { - return Err(CatalystSignedDocError::new( - error_report, - anyhow::anyhow!("Verification failed for Catalyst Signed Document"), - )); + } } - Ok(()) + Ok(!self.report().is_problematic()) } /// Returns a signed document `Builder` pre-loaded with the current signed document's /// data. - #[must_use] - pub fn into_builder(self) -> Builder { - Builder::new() + /// + /// # Errors + /// Fails if the `CatalystSignedDocument` object is not valid. + pub fn into_builder(self) -> anyhow::Result { + if self.report().is_problematic() { + anyhow::bail!("Invalid Document"); + } + Ok(Builder::new() .with_metadata(self.inner.metadata.clone()) - .with_decoded_content(self.inner.content.decoded_bytes().to_vec()) - .with_signatures(self.inner.signatures.clone()) + .with_decoded_content(self.doc_content().decoded_bytes()?.to_vec()) + .with_signatures(self.signatures().clone())) } /// Convert Catalyst Signed Document into `coset::CoseSign` + /// + /// # Errors + /// Could fails if the `CatalystSignedDocument` object is not valid. fn as_cose_sign(&self) -> anyhow::Result { - let mut cose_bytes: Vec = Vec::new(); - minicbor::encode(self, &mut cose_bytes)?; - coset::CoseSign::from_slice(&cose_bytes) - .map_err(|e| anyhow::anyhow!("encoding as COSE SIGN failed: {e}")) - } -} + let protected_header = Header::try_from(&self.inner.metadata) + .map_err(|e| anyhow::anyhow!("Failed to encode Document Metadata: {e}"))?; + + let content = if let Some(content_encoding) = self.doc_content_encoding() { + self.doc_content().encoded_bytes(content_encoding)? + } else { + self.doc_content().decoded_bytes()?.to_vec() + }; -impl TryFrom<&[u8]> for CatalystSignedDocument { - type Error = CatalystSignedDocError; + let mut builder = coset::CoseSignBuilder::new() + .protected(protected_header) + .payload(content); - fn try_from(value: &[u8]) -> Result { - let error_report = ProblemReport::new("Catalyst Signed Document"); - let mut ctx = DecodeSignDocCtx { error_report }; - let decoded: CatalystSignedDocument = minicbor::decode_with(value, &mut ctx) - .map_err(|e| CatalystSignedDocError::new(ctx.error_report, e.into()))?; - Ok(decoded) + for signature in self.signatures().cose_signatures() { + builder = builder.add_signature(signature); + } + Ok(builder.build()) } } -impl Decode<'_, DecodeSignDocCtx> for CatalystSignedDocument { - fn decode(d: &mut Decoder<'_>, ctx: &mut DecodeSignDocCtx) -> Result { +impl Decode<'_, ()> for CatalystSignedDocument { + fn decode(d: &mut Decoder<'_>, _ctx: &mut ()) -> Result { let start = d.position(); d.skip()?; let end = d.position(); @@ -238,82 +270,32 @@ impl Decode<'_, DecodeSignDocCtx> for CatalystSignedDocument { .ok_or(minicbor::decode::Error::end_of_input())?; let cose_sign = coset::CoseSign::from_slice(cose_bytes).map_err(|e| { - ctx.error_report.invalid_value( - "COSE sign document bytes", - &format!("{:?}", &cose_bytes), - &format!("Cannot convert bytes to CoseSign {e:?}"), - "Creating COSE Sign document", - ); minicbor::decode::Error::message(format!("Invalid COSE Sign document: {e}")) })?; - let metadata = Metadata::from_protected_header(&cose_sign.protected, &ctx.error_report) - .map_or_else( - |e| { - ctx.error_report.conversion_error( - "COSE sign protected header", - &format!("{:?}", &cose_sign.protected), - &format!("Expected Metadata: {e:?}"), - "Converting COSE Sign protected header to Metadata", - ); - None - }, - Some, - ); - let signatures = Signatures::from_cose_sig(&cose_sign.signatures, &ctx.error_report) - .map_or_else( - |e| { - ctx.error_report.conversion_error( - "COSE sign signatures", - &format!("{:?}", &cose_sign.signatures), - &format!("Expected Signatures {e:?}"), - "Converting COSE Sign signatures to Signatures", - ); - None - }, - Some, - ); - - if cose_sign.payload.is_none() { - ctx.error_report - .missing_field("COSE Sign Payload", "Missing document content (payload)"); - } - - match (cose_sign.payload, metadata, signatures) { - (Some(payload), Some(metadata), Some(signatures)) => { - let content = Content::from_encoded( - payload.clone(), - metadata.content_type(), - metadata.content_encoding(), - ) - .map_err(|e| { - ctx.error_report.invalid_value( - "Document Content", - &format!( - "Given value {:?}, {:?}, {:?}", - payload, - metadata.content_type(), - metadata.content_encoding() - ), - &format!("{e:?}"), - "Creating document content", - ); - minicbor::decode::Error::message("Failed to create Document Content") - })?; - - Ok(InnerCatalystSignedDocument { - metadata, - content, - signatures, - } - .into()) - }, - _ => { - Err(minicbor::decode::Error::message( - "Failed to decode Catalyst Signed Document", - )) - }, + let report = ProblemReport::new(PROBLEM_REPORT_CTX); + let metadata = Metadata::from_protected_header(&cose_sign.protected, &report); + let signatures = Signatures::from_cose_sig(&cose_sign.signatures, &report); + + let content = if let Some(payload) = cose_sign.payload { + Content::from_encoded( + payload, + metadata.content_type().ok(), + metadata.content_encoding(), + &report, + ) + } else { + report.missing_field("COSE Sign Payload", "Missing document content (payload)"); + Content::default() + }; + + Ok(InnerCatalystSignedDocument { + metadata, + content, + signatures, + report, } + .into()) } } @@ -321,24 +303,7 @@ impl Encode<()> for CatalystSignedDocument { fn encode( &self, e: &mut encode::Encoder, _ctx: &mut (), ) -> Result<(), encode::Error> { - let protected_header = Header::try_from(&self.inner.metadata).map_err(|e| { - minicbor::encode::Error::message(format!("Failed to encode Document Metadata: {e}")) - })?; - - let mut builder = coset::CoseSignBuilder::new() - .protected(protected_header) - .payload( - self.inner - .content - .encoded_bytes() - .map_err(encode::Error::message)?, - ); - - for signature in self.signatures().cose_signatures() { - builder = builder.add_signature(signature); - } - - let cose_sign = builder.build(); + let cose_sign = self.as_cose_sign().map_err(encode::Error::message)?; let cose_bytes = cose_sign.to_vec().map_err(|e| { minicbor::encode::Error::message(format!("Failed to encode COSE Sign document: {e}")) @@ -354,21 +319,23 @@ impl Encode<()> for CatalystSignedDocument { mod tests { use std::str::FromStr; - use ed25519_dalek::SigningKey; + use ed25519_dalek::{SigningKey, VerifyingKey}; use metadata::{ContentEncoding, ContentType}; use rand::rngs::OsRng; use super::*; fn test_metadata() -> anyhow::Result<(UuidV7, UuidV4, Metadata)> { + let alg = Algorithm::EdDSA; let uuid_v7 = UuidV7::new(); let uuid_v4 = UuidV4::new(); - let section = "some section".to_string(); + let section = "$".to_string(); let collabs = vec!["Alex1".to_string(), "Alex2".to_string()]; let content_type = ContentType::Json; let content_encoding = ContentEncoding::Brotli; let metadata: Metadata = serde_json::from_value(serde_json::json!({ + "alg": alg.to_string(), "content-type": content_type.to_string(), "content-encoding": content_encoding.to_string(), "type": uuid_v4.to_string(), @@ -384,7 +351,9 @@ mod tests { "brand_id": {"id": uuid_v7.to_string()}, "category_id": {"id": uuid_v7.to_string()}, })) - .map_err(|_| anyhow::anyhow!("Invalid example metadata. This should not happen."))?; + .map_err(|e| { + anyhow::anyhow!("Invalid example metadata. This should not happen. Err: {e}") + })?; Ok((uuid_v7, uuid_v4, metadata)) } @@ -400,17 +369,27 @@ mod tests { .unwrap(); let bytes = minicbor::to_vec(doc).unwrap(); - let decoded: CatalystSignedDocument = bytes.as_slice().try_into().unwrap(); + let decoded: CatalystSignedDocument = minicbor::decode(bytes.as_slice()).unwrap(); - assert_eq!(decoded.doc_type(), uuid_v4); - assert_eq!(decoded.doc_id(), uuid_v7); - assert_eq!(decoded.doc_ver(), uuid_v7); - assert_eq!(decoded.doc_content().decoded_bytes(), &content); + assert_eq!(decoded.doc_type().unwrap(), uuid_v4); + assert_eq!(decoded.doc_id().unwrap(), uuid_v7); + assert_eq!(decoded.doc_ver().unwrap(), uuid_v7); + assert_eq!(decoded.doc_content().decoded_bytes().unwrap(), &content); assert_eq!(decoded.doc_meta(), metadata.extra()); } - #[test] - fn signature_verification_test() { + struct Provider(anyhow::Result>); + impl VerifyingKeyProvider for Provider { + async fn try_get_key( + &self, _kid: &IdUri, + ) -> anyhow::Result> { + let res = self.0.as_ref().map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(*res) + } + } + + #[tokio::test] + async fn signature_verification_test() { let mut csprng = OsRng; let sk: SigningKey = SigningKey::generate(&mut csprng); let content = serde_json::to_vec(&serde_json::Value::Null).unwrap(); @@ -430,17 +409,13 @@ mod tests { .unwrap() .build() .unwrap(); + assert!(!signed_doc.problem_report().is_problematic()); assert!(signed_doc - .verify(|_| { SimplePublicKeyType::Ed25519(pk) }) - .is_ok()); - - assert!(signed_doc - .verify(|_| { SimplePublicKeyType::Undefined }) - .is_err()); - - assert!(signed_doc - .verify(|_| { SimplePublicKeyType::Deleted }) + .verify(&Provider(Err(anyhow::anyhow!("some error")))) + .await .is_err()); + assert!(signed_doc.verify(&Provider(Ok(Some(pk)))).await.unwrap()); + assert!(!signed_doc.verify(&Provider(Ok(None))).await.unwrap()); } } diff --git a/rust/signed_doc/src/metadata/algorithm.rs b/rust/signed_doc/src/metadata/algorithm.rs index 731aab67fa..f5c13e9273 100644 --- a/rust/signed_doc/src/metadata/algorithm.rs +++ b/rust/signed_doc/src/metadata/algorithm.rs @@ -1,5 +1,7 @@ //! Cryptographic Algorithm in COSE SIGN protected header. +use std::fmt::{Display, Formatter}; + use strum::VariantArray; /// Cryptography Algorithm. @@ -15,6 +17,14 @@ impl Default for Algorithm { } } +impl Display for Algorithm { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + Self::EdDSA => write!(f, "EdDSA"), + } + } +} + impl From for coset::iana::Algorithm { fn from(_: Algorithm) -> Self { coset::iana::Algorithm::EdDSA diff --git a/rust/signed_doc/src/metadata/content_encoding.rs b/rust/signed_doc/src/metadata/content_encoding.rs index 21f2d8b4cb..d47f696e7f 100644 --- a/rust/signed_doc/src/metadata/content_encoding.rs +++ b/rust/signed_doc/src/metadata/content_encoding.rs @@ -16,6 +16,9 @@ pub enum ContentEncoding { impl ContentEncoding { /// Compress a Brotli payload + /// + /// # Errors + /// Returns compression failure pub fn encode(self, mut payload: &[u8]) -> anyhow::Result> { match self { Self::Brotli => { @@ -28,6 +31,9 @@ impl ContentEncoding { } /// Decompress a Brotli payload + /// + /// # Errors + /// Returns decompression failure pub fn decode(self, mut payload: &[u8]) -> anyhow::Result> { match self { Self::Brotli => { diff --git a/rust/signed_doc/src/metadata/content_type.rs b/rust/signed_doc/src/metadata/content_type.rs index 821596c92e..b72cb4b9c2 100644 --- a/rust/signed_doc/src/metadata/content_type.rs +++ b/rust/signed_doc/src/metadata/content_type.rs @@ -20,7 +20,7 @@ pub enum ContentType { impl ContentType { /// Validates the provided `content` bytes to be a defined `ContentType`. - pub fn validate(self, content: &[u8]) -> anyhow::Result<()> { + pub(crate) fn validate(self, content: &[u8]) -> anyhow::Result<()> { match self { Self::Json => { if let Err(e) = serde_json::from_slice::(content) { diff --git a/rust/signed_doc/src/metadata/document_ref.rs b/rust/signed_doc/src/metadata/document_ref.rs index eaffaa58b2..a225155073 100644 --- a/rust/signed_doc/src/metadata/document_ref.rs +++ b/rust/signed_doc/src/metadata/document_ref.rs @@ -1,4 +1,7 @@ //! Catalyst Signed Document Metadata. + +use std::fmt::Display; + use coset::cbor::Value; use super::{decode_cbor_uuid, encode_cbor_uuid, UuidV7}; @@ -13,13 +16,12 @@ pub struct DocumentRef { pub ver: Option, } -impl DocumentRef { - /// Determine if internal `UUID`s are valid. - #[must_use] - pub fn is_valid(&self) -> bool { - match self.ver { - Some(ver) => self.id.is_valid() && ver.is_valid() && ver >= self.id, - None => self.id.is_valid(), +impl Display for DocumentRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(ver) = self.ver { + write!(f, "id: {}, ver: {}", self.id, ver) + } else { + write!(f, "id: {}", self.id) } } } diff --git a/rust/signed_doc/src/metadata/extra_fields.rs b/rust/signed_doc/src/metadata/extra_fields.rs index f8744c8470..b207f2e977 100644 --- a/rust/signed_doc/src/metadata/extra_fields.rs +++ b/rust/signed_doc/src/metadata/extra_fields.rs @@ -3,7 +3,7 @@ use catalyst_types::{problem_report::ProblemReport, uuid::UuidV4}; use coset::{cbor::Value, Label, ProtectedHeader}; -use super::{cose_protected_header_find, decode_cbor_uuid, encode_cbor_uuid, DocumentRef}; +use super::{cose_protected_header_find, decode_cbor_uuid, encode_cbor_uuid, DocumentRef, Section}; /// `ref` field COSE key value const REF_KEY: &str = "ref"; @@ -40,7 +40,7 @@ pub struct ExtraFields { reply: Option, /// Reference to the document section. #[serde(skip_serializing_if = "Option::is_none")] - section: Option, + section: Option

, /// Reference to the document collaborators. Collaborator type is TBD. #[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")] collabs: Vec, @@ -79,7 +79,7 @@ impl ExtraFields { /// Return `section` field. #[must_use] - pub fn section(&self) -> Option<&String> { + pub fn section(&self) -> Option<&Section> { self.section.as_ref() } @@ -128,7 +128,7 @@ impl ExtraFields { } if let Some(section) = &self.section { - builder = builder.text_value(SECTION_KEY.to_string(), Value::Text(section.clone())); + builder = builder.text_value(SECTION_KEY.to_string(), Value::from(section.clone())); } if !self.collabs.is_empty() { @@ -163,202 +163,163 @@ impl ExtraFields { #[allow(clippy::too_many_lines)] pub(crate) fn from_protected_header( protected: &ProtectedHeader, error_report: &ProblemReport, - ) -> Option { + ) -> Self { /// Context for error messages. const CONTEXT: &str = "COSE ProtectedHeader to ExtraFields"; - let mut valid = true; - let mut extra = ExtraFields::default(); if let Some(cbor_doc_ref) = cose_protected_header_find(protected, |key| key == &Label::Text(REF_KEY.to_string())) { - match DocumentRef::try_from(cbor_doc_ref) { - Ok(doc_ref) => { - extra.doc_ref = Some(doc_ref); - }, - Err(e) => { - valid = false; - error_report.conversion_error( - "CBOR COSE protected header doc ref", - &format!("{cbor_doc_ref:?}"), - &format!("Expected DocumentRef: {e}"), - &format!("{CONTEXT}, DocumentRef"), - ); - }, - } + if let Ok(doc_ref) = DocumentRef::try_from(cbor_doc_ref) { + extra.doc_ref = Some(doc_ref); + } else { + error_report.conversion_error( + "CBOR COSE protected header doc ref", + &format!("{cbor_doc_ref:?}"), + "Expected a CBOR DocumentRef", + &format!("{CONTEXT}, DocumentRef"), + ); + }; } if let Some(cbor_doc_template) = cose_protected_header_find(protected, |key| { key == &Label::Text(TEMPLATE_KEY.to_string()) }) { - match DocumentRef::try_from(cbor_doc_template) { - Ok(doc_template) => { - extra.template = Some(doc_template); - }, - Err(e) => { - valid = false; - error_report.conversion_error( - "CBOR COSE protected header document template", - &format!("{cbor_doc_template:?}"), - &format!("Expected DocumentRef: {e}"), - &format!("{CONTEXT}, DocumentRef"), - ); - }, + if let Ok(doc_template) = DocumentRef::try_from(cbor_doc_template) { + extra.template = Some(doc_template); + } else { + error_report.conversion_error( + "CBOR COSE protected header document template", + &format!("{cbor_doc_template:?}"), + "Expected a CBOR DocumentRef", + &format!("{CONTEXT}, DocumentRef"), + ); } } if let Some(cbor_doc_reply) = cose_protected_header_find(protected, |key| key == &Label::Text(REPLY_KEY.to_string())) { - match DocumentRef::try_from(cbor_doc_reply) { - Ok(doc_reply) => { - extra.reply = Some(doc_reply); - }, - Err(e) => { - valid = false; - error_report.conversion_error( - "CBOR COSE protected header document reply", - &format!("{cbor_doc_reply:?}"), - &format!("Expected DocumentRef: {e}"), - &format!("{CONTEXT}, DocumentRef"), - ); - }, + if let Ok(doc_reply) = DocumentRef::try_from(cbor_doc_reply) { + extra.reply = Some(doc_reply); + } else { + error_report.conversion_error( + "CBOR COSE protected header document reply", + &format!("{cbor_doc_reply:?}"), + "Expected a CBOR DocumentRef", + &format!("{CONTEXT}, DocumentRef"), + ); } } if let Some(cbor_doc_section) = cose_protected_header_find(protected, |key| { key == &Label::Text(SECTION_KEY.to_string()) }) { - match cbor_doc_section.clone().into_text() { - Ok(doc_section) => { - extra.section = Some(doc_section); - }, - Err(e) => { - valid = false; - error_report.conversion_error( - "COSE protected header document section", - &format!("{cbor_doc_section:?}"), - &format!("Expected String: {e:?}"), - &format!("{CONTEXT}, converting document section to String"), - ); - }, + if let Ok(section) = Section::try_from(cbor_doc_section) { + extra.section = Some(section); + } else { + error_report.conversion_error( + "COSE protected header document section", + &format!("{cbor_doc_section:?}"), + "Must be a valid CBOR encoded String JSON Path", + &format!("{CONTEXT}, converting document section to String JSON Path"), + ); } } if let Some(cbor_doc_collabs) = cose_protected_header_find(protected, |key| { key == &Label::Text(COLLABS_KEY.to_string()) }) { - match cbor_doc_collabs.clone().into_array() { - Ok(collabs) => { - let mut c = Vec::new(); - for (ids, collaborator) in collabs.iter().cloned().enumerate() { - match collaborator.clone().into_text() { - Ok(collaborator) => { - c.push(collaborator); - }, - Err(e) => { - valid = false; - error_report.conversion_error( - &format!("COSE protected header collaborator index {ids}"), - &format!("{collaborator:?}"), - &format!("Expected String: {e:?}"), - &format!("{CONTEXT}, converting collaborator to String"), - ); - }, - } + if let Ok(collabs) = cbor_doc_collabs.clone().into_array() { + let mut c = Vec::new(); + for (ids, collaborator) in collabs.iter().cloned().enumerate() { + match collaborator.clone().into_text() { + Ok(collaborator) => { + c.push(collaborator); + }, + Err(_) => { + error_report.conversion_error( + &format!("COSE protected header collaborator index {ids}"), + &format!("{collaborator:?}"), + "Expected a CBOR String", + &format!("{CONTEXT}, converting collaborator to String"), + ); + }, } - extra.collabs = c; - }, - Err(e) => { - valid = false; - error_report.conversion_error( - "CBOR COSE protected header collaborators", - &format!("{cbor_doc_collabs:?}"), - &format!("Expected Array: {e:?}"), - &format!("{CONTEXT}, converting collaborators to Array"), - ); - }, - } + } + extra.collabs = c; + } else { + error_report.conversion_error( + "CBOR COSE protected header collaborators", + &format!("{cbor_doc_collabs:?}"), + "Expected a CBOR Array", + &format!("{CONTEXT}, converting collaborators to Array"), + ); + }; } if let Some(cbor_doc_brand_id) = cose_protected_header_find(protected, |key| { key == &Label::Text(BRAND_ID_KEY.to_string()) }) { - match DocumentRef::try_from(cbor_doc_brand_id) { - Ok(brand_id) => { - extra.brand_id = Some(brand_id); - }, - Err(e) => { - valid = false; - error_report.conversion_error( - "CBOR COSE protected header brand ID", - &format!("{cbor_doc_brand_id:?}"), - &format!("Expected UUID: {e:?}"), - &format!("{CONTEXT}, decoding CBOR UUID for brand ID"), - ); - }, + if let Ok(brand_id) = DocumentRef::try_from(cbor_doc_brand_id) { + extra.brand_id = Some(brand_id); + } else { + error_report.conversion_error( + "CBOR COSE protected header brand ID", + &format!("{cbor_doc_brand_id:?}"), + "Expected a CBOR UUID", + &format!("{CONTEXT}, decoding CBOR UUID for brand ID"), + ); } } if let Some(cbor_doc_campaign_id) = cose_protected_header_find(protected, |key| { key == &Label::Text(CAMPAIGN_ID_KEY.to_string()) }) { - match DocumentRef::try_from(cbor_doc_campaign_id) { - Ok(campaign_id) => { - extra.campaign_id = Some(campaign_id); - }, - Err(e) => { - valid = false; - error_report.conversion_error( - "CBOR COSE protected header campaign ID", - &format!("{cbor_doc_campaign_id:?}"), - &format!("Expected UUID: {e:?}"), - &format!("{CONTEXT}, decoding CBOR UUID for campaign ID"), - ); - }, + if let Ok(campaign_id) = DocumentRef::try_from(cbor_doc_campaign_id) { + extra.campaign_id = Some(campaign_id); + } else { + error_report.conversion_error( + "CBOR COSE protected header campaign ID", + &format!("{cbor_doc_campaign_id:?}"), + "Expected a CBOR UUID", + &format!("{CONTEXT}, decoding CBOR UUID for campaign ID"), + ); } } if let Some(cbor_doc_election_id) = cose_protected_header_find(protected, |key| { key == &Label::Text(ELECTION_ID_KEY.to_string()) }) { - match decode_cbor_uuid(cbor_doc_election_id.clone()) { - Ok(election_id) => { - extra.election_id = Some(election_id); - }, - Err(e) => { - valid = false; - error_report.conversion_error( - "CBOR COSE protected header election ID", - &format!("{cbor_doc_election_id:?}"), - &format!("Expected UUID: {e:?}"), - &format!("{CONTEXT}, decoding CBOR UUID for election ID"), - ); - }, + if let Ok(election_id) = decode_cbor_uuid(cbor_doc_election_id.clone()) { + extra.election_id = Some(election_id); + } else { + error_report.conversion_error( + "CBOR COSE protected header election ID", + &format!("{cbor_doc_election_id:?}"), + "Expected a CBOR UUID", + &format!("{CONTEXT}, decoding CBOR UUID for election ID"), + ); } } if let Some(cbor_doc_category_id) = cose_protected_header_find(protected, |key| { key == &Label::Text(CATEGORY_ID_KEY.to_string()) }) { - match DocumentRef::try_from(cbor_doc_category_id) { - Ok(category_id) => { - extra.category_id = Some(category_id); - }, - Err(e) => { - valid = false; - error_report.conversion_error( - "CBOR COSE protected header category ID", - &format!("{cbor_doc_category_id:?}"), - &format!("Expected UUID: {e:?}"), - &format!("{CONTEXT}, decoding CBOR UUID for category ID"), - ); - }, + if let Ok(category_id) = DocumentRef::try_from(cbor_doc_category_id) { + extra.category_id = Some(category_id); + } else { + error_report.conversion_error( + "CBOR COSE protected header category ID", + &format!("{cbor_doc_category_id:?}"), + "Expected a CBOR UUID", + &format!("{CONTEXT}, decoding CBOR UUID for category ID"), + ); } } - valid.then_some(extra) + extra } } diff --git a/rust/signed_doc/src/metadata/mod.rs b/rust/signed_doc/src/metadata/mod.rs index bbc42be07d..8b808d508d 100644 --- a/rust/signed_doc/src/metadata/mod.rs +++ b/rust/signed_doc/src/metadata/mod.rs @@ -6,18 +6,21 @@ mod content_encoding; mod content_type; mod document_ref; mod extra_fields; +mod section; +pub(crate) mod utils; -use algorithm::Algorithm; -use anyhow::{anyhow, bail}; +pub use algorithm::Algorithm; use catalyst_types::{ problem_report::ProblemReport, - uuid::{CborContext, UuidV4, UuidV7}, + uuid::{UuidV4, UuidV7}, }; pub use content_encoding::ContentEncoding; pub use content_type::ContentType; -use coset::{iana::CoapContentFormat, CborSerializable}; +use coset::iana::CoapContentFormat; pub use document_ref::DocumentRef; pub use extra_fields::ExtraFields; +pub use section::Section; +use utils::{cose_protected_header_find, decode_cbor_uuid, encode_cbor_uuid, validate_option}; /// `content_encoding` field COSE key value const CONTENT_ENCODING_KEY: &str = "Content-Encoding"; @@ -31,23 +34,25 @@ const VER_KEY: &str = "ver"; /// Document Metadata. /// /// These values are extracted from the COSE Sign protected header. -#[derive(Clone, Debug, PartialEq, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, serde::Deserialize, Default)] pub struct Metadata { /// Cryptographic Algorithm - #[serde(default = "Algorithm::default")] - alg: Algorithm, + #[serde(deserialize_with = "validate_option")] + alg: Option, /// Document Type `UUIDv4`. - #[serde(rename = "type")] - doc_type: UuidV4, + #[serde(rename = "type", deserialize_with = "validate_option")] + doc_type: Option, /// Document ID `UUIDv7`. - id: UuidV7, + #[serde(deserialize_with = "validate_option")] + id: Option, /// Document Version `UUIDv7`. - ver: UuidV7, + #[serde(deserialize_with = "validate_option")] + ver: Option, /// Document Payload Content Type. - #[serde(rename = "content-type")] - content_type: ContentType, + #[serde(rename = "content-type", deserialize_with = "validate_option")] + content_type: Option, /// Document Payload Content Encoding. - #[serde(rename = "content-encoding", skip_serializing_if = "Option::is_none")] + #[serde(rename = "content-encoding")] content_encoding: Option, /// Additional Metadata Fields. #[serde(flatten)] @@ -56,33 +61,44 @@ pub struct Metadata { impl Metadata { /// Return Document Cryptographic Algorithm - #[must_use] - pub fn algorithm(&self) -> Algorithm { - self.alg + /// + /// # Errors + /// - Missing 'alg' field. + pub fn algorithm(&self) -> anyhow::Result { + self.alg.ok_or(anyhow::anyhow!("Missing 'alg' field")) } /// Return Document Type `UUIDv4`. - #[must_use] - pub fn doc_type(&self) -> UuidV4 { - self.doc_type + /// + /// # Errors + /// - Missing 'type' field. + pub fn doc_type(&self) -> anyhow::Result { + self.doc_type.ok_or(anyhow::anyhow!("Missing 'type' field")) } /// Return Document ID `UUIDv7`. - #[must_use] - pub fn doc_id(&self) -> UuidV7 { - self.id + /// + /// # Errors + /// - Missing 'id' field. + pub fn doc_id(&self) -> anyhow::Result { + self.id.ok_or(anyhow::anyhow!("Missing 'id' field")) } /// Return Document Version `UUIDv7`. - #[must_use] - pub fn doc_ver(&self) -> UuidV7 { - self.ver + /// + /// # Errors + /// - Missing 'ver' field. + pub fn doc_ver(&self) -> anyhow::Result { + self.ver.ok_or(anyhow::anyhow!("Missing 'ver' field")) } /// Returns the Document Content Type, if any. - #[must_use] - pub fn content_type(&self) -> ContentType { + /// + /// # Errors + /// - Missing 'content-type' field. + pub fn content_type(&self) -> anyhow::Result { self.content_type + .ok_or(anyhow::anyhow!("Missing 'content-type' field")) } /// Returns the Document Content Encoding, if any. @@ -101,14 +117,19 @@ impl Metadata { #[allow(clippy::too_many_lines)] pub(crate) fn from_protected_header( protected: &coset::ProtectedHeader, error_report: &ProblemReport, - ) -> anyhow::Result { + ) -> Self { /// Context for error messages. const CONTEXT: &str = "COSE Protected Header to Metadata"; - let mut algorithm = Algorithm::default(); + let extra = ExtraFields::from_protected_header(protected, error_report); + let mut metadata = Metadata { + extra, + ..Metadata::default() + }; + if let Some(coset::RegisteredLabelWithPrivate::Assigned(alg)) = protected.header.alg { match Algorithm::try_from(alg) { - Ok(alg) => algorithm = alg, + Ok(alg) => metadata.alg = Some(alg), Err(e) => { error_report.conversion_error( "COSE protected header algorithm", @@ -122,10 +143,9 @@ impl Metadata { error_report.missing_field("alg", "Missing alg field in COSE protected header"); } - let mut content_type = None; if let Some(value) = protected.header.content_type.as_ref() { match ContentType::try_from(value) { - Ok(ct) => content_type = Some(ct), + Ok(ct) => metadata.content_type = Some(ct), Err(e) => { error_report.conversion_error( "COSE protected header content type", @@ -142,13 +162,12 @@ impl Metadata { ); } - let mut content_encoding = None; if let Some(value) = cose_protected_header_find( protected, |key| matches!(key, coset::Label::Text(label) if label.eq_ignore_ascii_case(CONTENT_ENCODING_KEY)), ) { match ContentEncoding::try_from(value) { - Ok(ce) => content_encoding = Some(ce), + Ok(ce) => metadata.content_encoding = Some(ce), Err(e) => { error_report.conversion_error( "COSE protected header content encoding", @@ -160,12 +179,11 @@ impl Metadata { } } - let mut doc_type: Option = None; if let Some(value) = cose_protected_header_find(protected, |key| { key == &coset::Label::Text(TYPE_KEY.to_string()) }) { match decode_cbor_uuid(value.clone()) { - Ok(uuid) => doc_type = Some(uuid), + Ok(uuid) => metadata.doc_type = Some(uuid), Err(e) => { error_report.conversion_error( "COSE protected header type", @@ -179,12 +197,11 @@ impl Metadata { error_report.missing_field("type", "Missing type field in COSE protected header"); } - let mut id: Option = None; if let Some(value) = cose_protected_header_find(protected, |key| { key == &coset::Label::Text(ID_KEY.to_string()) }) { match decode_cbor_uuid(value.clone()) { - Ok(uuid) => id = Some(uuid), + Ok(uuid) => metadata.id = Some(uuid), Err(e) => { error_report.conversion_error( "COSE protected header ID", @@ -198,12 +215,11 @@ impl Metadata { error_report.missing_field("id", "Missing id field in COSE protected header"); } - let mut ver: Option = None; if let Some(value) = cose_protected_header_find(protected, |key| { key == &coset::Label::Text(VER_KEY.to_string()) }) { match decode_cbor_uuid(value.clone()) { - Ok(uuid) => ver = Some(uuid), + Ok(uuid) => metadata.ver = Some(uuid), Err(e) => { error_report.conversion_error( "COSE protected header ver", @@ -217,51 +233,33 @@ impl Metadata { error_report.missing_field("ver", "Missing ver field in COSE protected header"); } - let extra = ExtraFields::from_protected_header(protected, error_report); - - match (content_type, content_encoding, id, doc_type, ver, extra) { - ( - Some(content_type), - content_encoding, - Some(id), - Some(doc_type), - Some(ver), - Some(extra), - ) => { + if let Some(id) = metadata.id { + if let Some(ver) = metadata.ver { if ver < id { error_report.invalid_value( "ver", &ver.to_string(), "ver < id", - &format!("{CONTEXT}, Document Version {ver} cannot be smaller than Document ID {id}"), + &format!( + "{CONTEXT}, Document Version {ver} cannot be smaller than Document ID {id}" + ), ); - - bail!("Failed to convert COSE Protected Header to Metadata: document version is smaller than document ID"); } - - Ok(Self { - doc_type, - id, - ver, - alg: algorithm, - content_encoding, - content_type, - extra, - }) - }, - _ => bail!("Failed to convert COSE Protected Header to Metadata"), + } } + + metadata } } impl Display for Metadata { fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { writeln!(f, "Metadata {{")?; - writeln!(f, " type: {},", self.doc_type)?; - writeln!(f, " id: {},", self.id)?; - writeln!(f, " ver: {},", self.ver)?; + writeln!(f, " type: {:?},", self.doc_type)?; + writeln!(f, " id: {:?},", self.id)?; + writeln!(f, " ver: {:?},", self.ver)?; writeln!(f, " alg: {:?},", self.alg)?; - writeln!(f, " content_type: {}", self.content_type)?; + writeln!(f, " content_type: {:?}", self.content_type)?; writeln!(f, " content_encoding: {:?}", self.content_encoding)?; writeln!(f, " additional_fields: {:?},", self.extra)?; writeln!(f, "}}") @@ -273,8 +271,12 @@ impl TryFrom<&Metadata> for coset::Header { fn try_from(meta: &Metadata) -> Result { let mut builder = coset::HeaderBuilder::new() - .algorithm(meta.alg.into()) - .content_format(CoapContentFormat::from(meta.content_type())); + .algorithm( + meta.alg + .ok_or(anyhow::anyhow!("missing `alg` field"))? + .into(), + ) + .content_format(CoapContentFormat::from(meta.content_type()?)); if let Some(content_encoding) = meta.content_encoding() { builder = builder.text_value( @@ -284,9 +286,9 @@ impl TryFrom<&Metadata> for coset::Header { } builder = builder - .text_value(TYPE_KEY.to_string(), encode_cbor_uuid(meta.doc_type)?) - .text_value(ID_KEY.to_string(), encode_cbor_uuid(meta.id)?) - .text_value(VER_KEY.to_string(), encode_cbor_uuid(meta.ver)?); + .text_value(TYPE_KEY.to_string(), encode_cbor_uuid(meta.doc_type()?)?) + .text_value(ID_KEY.to_string(), encode_cbor_uuid(meta.doc_id()?)?) + .text_value(VER_KEY.to_string(), encode_cbor_uuid(meta.doc_ver()?)?); builder = meta.extra.fill_cose_header_fields(builder)?; @@ -294,42 +296,68 @@ impl TryFrom<&Metadata> for coset::Header { } } -/// Find a value for a predicate in the protected header. -fn cose_protected_header_find( - protected: &coset::ProtectedHeader, mut predicate: impl FnMut(&coset::Label) -> bool, -) -> Option<&coset::cbor::Value> { - protected - .header - .rest - .iter() - .find(|(key, _)| predicate(key)) - .map(|(_, value)| value) -} - -/// Encode `uuid::Uuid` type into `coset::cbor::Value`. -/// -/// This is used to encode `UuidV4` and `UuidV7` types. -pub(crate) fn encode_cbor_uuid>( - value: T, -) -> anyhow::Result { - let mut cbor_bytes = Vec::new(); - minicbor::encode_with(value, &mut cbor_bytes, &mut CborContext::Tagged) - .map_err(|e| anyhow::anyhow!("Unable to encode CBOR value, err: {e}"))?; - coset::cbor::Value::from_slice(&cbor_bytes) - .map_err(|e| anyhow::anyhow!("Invalid CBOR value, err: {e}")) -} - -/// Decode `From` type from `coset::cbor::Value`. -/// -/// This is used to decode `UuidV4` and `UuidV7` types. -pub(crate) fn decode_cbor_uuid minicbor::decode::Decode<'a, CborContext>>( - value: coset::cbor::Value, -) -> anyhow::Result { - match value.to_vec() { - Ok(cbor_value) => { - minicbor::decode_with(&cbor_value, &mut CborContext::Tagged) - .map_err(|e| anyhow!("Invalid UUID, err: {e}")) - }, - Err(e) => anyhow::bail!("Invalid CBOR value, err: {e}"), +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_serde_test() { + let alg = Algorithm::EdDSA; + let uuid_v7 = UuidV7::new(); + let uuid_v4 = UuidV4::new(); + let content_type = ContentType::Json; + + let valid = serde_json::json!({ + "alg": alg.to_string(), + "content-type": content_type.to_string(), + "type": uuid_v4.to_string(), + "id": uuid_v7.to_string(), + "ver": uuid_v7.to_string(), + + }); + assert!(serde_json::from_value::(valid).is_ok()); + + let missing_alg = serde_json::json!({ + "content-type": content_type.to_string(), + "type": uuid_v4.to_string(), + "id": uuid_v7.to_string(), + "ver": uuid_v7.to_string(), + + }); + assert!(serde_json::from_value::(missing_alg).is_err()); + + let missing_content_type = serde_json::json!({ + "alg": alg.to_string(), + "type": uuid_v4.to_string(), + "id": uuid_v7.to_string(), + "ver": uuid_v7.to_string(), + }); + assert!(serde_json::from_value::(missing_content_type).is_err()); + + let missing_type = serde_json::json!({ + "alg": alg.to_string(), + "content-type": content_type.to_string(), + "id": uuid_v7.to_string(), + "ver": uuid_v7.to_string(), + + }); + assert!(serde_json::from_value::(missing_type).is_err()); + + let missing_id = serde_json::json!({ + "alg": alg.to_string(), + "content-type": content_type.to_string(), + "type": uuid_v4.to_string(), + "ver": uuid_v7.to_string(), + + }); + assert!(serde_json::from_value::(missing_id).is_err()); + + let missing_ver = serde_json::json!({ + "alg": alg.to_string(), + "content-type": content_type.to_string(), + "type": uuid_v4.to_string(), + "id": uuid_v7.to_string(), + }); + assert!(serde_json::from_value::(missing_ver).is_err()); } } diff --git a/rust/signed_doc/src/metadata/section.rs b/rust/signed_doc/src/metadata/section.rs new file mode 100644 index 0000000000..01e6a02a1b --- /dev/null +++ b/rust/signed_doc/src/metadata/section.rs @@ -0,0 +1,58 @@ +//! Catalyst Signed Document `section` field type definition. + +use std::{fmt::Display, str::FromStr}; + +use coset::cbor::Value; +use serde::{Deserialize, Serialize}; + +/// 'section' field type definition, which is a JSON path string +#[derive(Clone, Debug, PartialEq)] +pub struct Section(jsonpath_rust::JsonPath); + +impl Display for Section { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl Serialize for Section { + fn serialize(&self, serializer: S) -> Result + where S: serde::Serializer { + self.to_string().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for Section { + fn deserialize(deserializer: D) -> Result + where D: serde::Deserializer<'de> { + let str = String::deserialize(deserializer)?; + Self::from_str(&str).map_err(serde::de::Error::custom) + } +} + +impl FromStr for Section { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + Ok(Self( + jsonpath_rust::JsonPath::::from_str(s)?, + )) + } +} + +impl From
for Value { + fn from(value: Section) -> Self { + Value::Text(value.to_string()) + } +} + +impl TryFrom<&Value> for Section { + type Error = anyhow::Error; + + fn try_from(val: &Value) -> anyhow::Result { + let str = val + .as_text() + .ok_or(anyhow::anyhow!("Not a cbor string type"))?; + Self::from_str(str) + } +} diff --git a/rust/signed_doc/src/metadata/utils.rs b/rust/signed_doc/src/metadata/utils.rs new file mode 100644 index 0000000000..fbf4c5fc0c --- /dev/null +++ b/rust/signed_doc/src/metadata/utils.rs @@ -0,0 +1,60 @@ +//! Utility functions for metadata decoding fields + +use catalyst_types::uuid::CborContext; +use coset::CborSerializable; +use serde::{Deserialize, Deserializer}; + +/// Custom serde deserialization function that fails if the field is `None` +pub(crate) fn validate_option<'de, T, D>(deserializer: D) -> Result, D::Error> +where + T: Deserialize<'de>, + D: Deserializer<'de>, +{ + let value = Option::deserialize(deserializer)?; + if value.is_none() { + return Err(serde::de::Error::custom( + "Field is required but was missing or null", + )); + } + Ok(value) +} + +/// Find a value for a predicate in the protected header. +pub(crate) fn cose_protected_header_find( + protected: &coset::ProtectedHeader, mut predicate: impl FnMut(&coset::Label) -> bool, +) -> Option<&coset::cbor::Value> { + protected + .header + .rest + .iter() + .find(|(key, _)| predicate(key)) + .map(|(_, value)| value) +} + +/// Encode `uuid::Uuid` type into `coset::cbor::Value`. +/// +/// This is used to encode `UuidV4` and `UuidV7` types. +pub(crate) fn encode_cbor_uuid>( + value: T, +) -> anyhow::Result { + let mut cbor_bytes = Vec::new(); + minicbor::encode_with(value, &mut cbor_bytes, &mut CborContext::Tagged) + .map_err(|e| anyhow::anyhow!("Unable to encode CBOR value, err: {e}"))?; + coset::cbor::Value::from_slice(&cbor_bytes) + .map_err(|e| anyhow::anyhow!("Invalid CBOR value, err: {e}")) +} + +/// Decode `From` type from `coset::cbor::Value`. +/// +/// This is used to decode `UuidV4` and `UuidV7` types. +pub(crate) fn decode_cbor_uuid minicbor::decode::Decode<'a, CborContext>>( + value: coset::cbor::Value, +) -> anyhow::Result { + match value.to_vec() { + Ok(cbor_value) => { + minicbor::decode_with(&cbor_value, &mut CborContext::Tagged) + .map_err(|e| anyhow::anyhow!("Invalid UUID, err: {e}")) + }, + Err(e) => anyhow::bail!("Invalid CBOR value, err: {e}"), + } +} diff --git a/rust/signed_doc/src/providers.rs b/rust/signed_doc/src/providers.rs new file mode 100644 index 0000000000..d3b4adb8ba --- /dev/null +++ b/rust/signed_doc/src/providers.rs @@ -0,0 +1,24 @@ +//! Providers traits, which are used during different validation procedures. + +use std::future::Future; + +use catalyst_types::id_uri::IdUri; +use ed25519_dalek::VerifyingKey; + +use crate::{CatalystSignedDocument, DocumentRef}; + +/// `VerifyingKey` Provider trait +pub trait VerifyingKeyProvider { + /// Try to get `VerifyingKey` + fn try_get_key( + &self, kid: &IdUri, + ) -> impl Future>>; +} + +/// `CatalystSignedDocument` Provider trait +pub trait CatalystSignedDocumentProvider: Send + Sync { + /// Try to get `CatalystSignedDocument` + fn try_get_doc( + &self, doc_ref: &DocumentRef, + ) -> impl Future>> + Send; +} diff --git a/rust/signed_doc/src/signature/mod.rs b/rust/signed_doc/src/signature/mod.rs index 4ef3f608be..3f49143d3f 100644 --- a/rust/signed_doc/src/signature/mod.rs +++ b/rust/signed_doc/src/signature/mod.rs @@ -1,6 +1,5 @@ //! Catalyst Signed Document COSE Signature information. -use anyhow::bail; pub use catalyst_types::id_uri::IdUri; use catalyst_types::problem_report::ProblemReport; use coset::CoseSignature; @@ -19,32 +18,32 @@ pub struct Signature { pub struct Signatures(Vec); impl Signatures { - /// Creates an empty signatures list. - #[must_use] - pub fn new() -> Self { - Self(Vec::new()) - } - /// Return a list of author IDs (short form of Catalyst IDs). #[must_use] - pub fn authors(&self) -> Vec { + pub(crate) fn authors(&self) -> Vec { self.kids().into_iter().map(|k| k.as_short_id()).collect() } /// Return a list of Document's Catalyst IDs. #[must_use] - pub fn kids(&self) -> Vec { + pub(crate) fn kids(&self) -> Vec { self.0.iter().map(|sig| sig.kid.clone()).collect() } - /// List of signatures. - #[must_use] - pub fn cose_signatures(&self) -> Vec { - self.0.iter().map(|sig| sig.signature.clone()).collect() + /// Iterator of COSE signatures object with kids. + pub(crate) fn cose_signatures_with_kids( + &self, + ) -> impl Iterator + use<'_> { + self.0.iter().map(|sig| (&sig.signature, &sig.kid)) + } + + /// List of COSE signatures object. + pub(crate) fn cose_signatures(&self) -> impl Iterator + use<'_> { + self.0.iter().map(|sig| sig.signature.clone()) } /// Add a new signature - pub fn push(&mut self, kid: IdUri, signature: CoseSignature) { + pub(crate) fn push(&mut self, kid: IdUri, signature: CoseSignature) { self.0.push(Signature { kid, signature }); } @@ -61,9 +60,7 @@ impl Signatures { } /// Convert list of COSE Signature to `Signatures`. - pub(crate) fn from_cose_sig( - cose_sigs: &[CoseSignature], error_report: &ProblemReport, - ) -> anyhow::Result { + pub(crate) fn from_cose_sig(cose_sigs: &[CoseSignature], error_report: &ProblemReport) -> Self { let mut signatures = Vec::new(); cose_sigs @@ -83,9 +80,7 @@ impl Signatures { }, } }); - if error_report.is_problematic() { - bail!("Failed to convert COSE Signatures to Signatures"); - } - Ok(Signatures(signatures)) + + Self(signatures) } } diff --git a/rust/signed_doc/src/utils/context.rs b/rust/signed_doc/src/utils/context.rs deleted file mode 100644 index ef48f42777..0000000000 --- a/rust/signed_doc/src/utils/context.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Contexts for the Signed Document. - -use catalyst_types::problem_report::ProblemReport; - -/// Sign Document Decoding Context. -pub(crate) struct DecodeSignDocCtx { - /// Error Report. - pub(crate) error_report: ProblemReport, -} diff --git a/rust/signed_doc/src/utils/mod.rs b/rust/signed_doc/src/utils/mod.rs deleted file mode 100644 index 8cfdf34fdb..0000000000 --- a/rust/signed_doc/src/utils/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Utility functions. - -pub(crate) mod context; diff --git a/rust/signed_doc/src/validator/mod.rs b/rust/signed_doc/src/validator/mod.rs index 7009e66198..a02c576f46 100644 --- a/rust/signed_doc/src/validator/mod.rs +++ b/rust/signed_doc/src/validator/mod.rs @@ -1,71 +1,102 @@ //! Catalyst Signed Documents validation -use catalyst_types::problem_report::ProblemReport; +pub(crate) mod rules; +pub(crate) mod utils; + +use std::{collections::HashMap, sync::LazyLock}; + +use catalyst_types::uuid::Uuid; +use rules::{ + CategoryRule, ContentEncodingRule, ContentTypeRule, RefRule, ReplyRule, Rules, SectionRule, + TemplateRule, +}; use crate::{ - doc_types::{DocumentType, ProposalDocument}, - error::CatalystSignedDocError, - CatalystSignedDocument, + doc_types::{ + COMMENT_DOCUMENT_UUID_TYPE, COMMENT_TEMPLATE_UUID_TYPE, PROPOSAL_DOCUMENT_UUID_TYPE, + PROPOSAL_TEMPLATE_UUID_TYPE, + }, + providers::CatalystSignedDocumentProvider, + CatalystSignedDocument, ContentEncoding, ContentType, }; -/// A comprehensive validation of the `CatalystSignedDocument`, -/// including a signature verification and document type based validation. -/// -/// # Errors -/// -/// Returns a report of validation failures and the source error. -pub fn validate( - doc: &CatalystSignedDocument, doc_getter: F, -) -> Result<(), CatalystSignedDocError> -where F: FnMut() -> Option { - let error_report = ProblemReport::new("Catalyst Signed Document Validation"); +/// A table representing a full set or validation rules per document id. +static DOCUMENT_RULES: LazyLock> = LazyLock::new(document_rules_init); + +/// `DOCUMENT_RULES` initialization function +fn document_rules_init() -> HashMap { + let mut document_rules_map = HashMap::new(); - let doc_type: DocumentType = match doc.doc_type().try_into() { - Ok(doc_type) => doc_type, - Err(e) => { - error_report.invalid_value( - "`type`", - &doc.doc_type().to_string(), - &e.to_string(), - "verifying document type", - ); - return Err(CatalystSignedDocError::new( - error_report, - anyhow::anyhow!("Validation of the Catalyst Signed Document failed"), - )); + let proposal_document_rules = Rules { + content_type: ContentTypeRule { + exp: ContentType::Json, + }, + content_encoding: ContentEncodingRule { + exp: ContentEncoding::Brotli, + optional: false, }, + template: TemplateRule::Specified { + exp_template_type: PROPOSAL_TEMPLATE_UUID_TYPE, + }, + category: CategoryRule::Specified { optional: false }, + doc_ref: RefRule::NotSpecified, + reply: ReplyRule::NotSpecified, + section: SectionRule::NotSpecified, }; + document_rules_map.insert(PROPOSAL_DOCUMENT_UUID_TYPE, proposal_document_rules); - #[allow(clippy::match_same_arms)] - match doc_type { - DocumentType::ProposalDocument => { - if let Ok(proposal_doc) = ProposalDocument::from_signed_doc(doc, &error_report) { - proposal_doc.validate_with_report(doc_getter, &error_report); - } + let comment_document_rules = Rules { + content_type: ContentTypeRule { + exp: ContentType::Json, + }, + content_encoding: ContentEncodingRule { + exp: ContentEncoding::Brotli, + optional: false, }, - DocumentType::ProposalTemplate => {}, - DocumentType::CommentDocument => {}, - DocumentType::CommentTemplate => {}, - DocumentType::ReviewDocument => {}, - DocumentType::ReviewTemplate => {}, - DocumentType::CategoryParametersDocument => {}, - DocumentType::CategoryParametersTemplate => {}, - DocumentType::CampaignParametersDocument => {}, - DocumentType::CampaignParametersTemplate => {}, - DocumentType::BrandParametersDocument => {}, - DocumentType::BrandParametersTemplate => {}, - DocumentType::ProposalActionDocument => {}, - DocumentType::PublicVoteTxV2 => {}, - DocumentType::PrivateVoteTxV2 => {}, - DocumentType::ImmutableLedgerBlock => {}, - } + template: TemplateRule::Specified { + exp_template_type: COMMENT_TEMPLATE_UUID_TYPE, + }, + doc_ref: RefRule::Specified { + exp_ref_type: PROPOSAL_DOCUMENT_UUID_TYPE, + optional: false, + }, + reply: ReplyRule::Specified { + exp_reply_type: COMMENT_DOCUMENT_UUID_TYPE, + optional: true, + }, + section: SectionRule::Specified { optional: true }, + category: CategoryRule::NotSpecified, + }; + document_rules_map.insert(COMMENT_DOCUMENT_UUID_TYPE, comment_document_rules); + + document_rules_map +} - if error_report.is_problematic() { - return Err(CatalystSignedDocError::new( - error_report, - anyhow::anyhow!("Validation of the Catalyst Signed Document failed"), - )); - } +/// A comprehensive document type based validation of the `CatalystSignedDocument`. +/// Return true if all signatures are valid, otherwise return false. +/// +/// # Errors +/// If `provider` returns error, fails fast throwing that error. +pub async fn validate( + doc: &CatalystSignedDocument, provider: &Provider, +) -> anyhow::Result +where Provider: 'static + CatalystSignedDocumentProvider { + let Ok(doc_type) = doc.doc_type() else { + doc.report().missing_field( + "type", + "Can't get a document type during the validation process", + ); + return Ok(false); + }; - Ok(()) + let Some(rules) = DOCUMENT_RULES.get(&doc_type.uuid()) else { + doc.report().invalid_value( + "`type`", + &doc.doc_type()?.to_string(), + "Must be a known document type value", + "Unsupported document type", + ); + return Ok(false); + }; + rules.check(doc, provider).await } diff --git a/rust/signed_doc/src/validator/rules/category.rs b/rust/signed_doc/src/validator/rules/category.rs new file mode 100644 index 0000000000..259e827539 --- /dev/null +++ b/rust/signed_doc/src/validator/rules/category.rs @@ -0,0 +1,61 @@ +//! `content-type` rule type impl. + +use crate::{ + doc_types::CATEGORY_DOCUMENT_UUID_TYPE, providers::CatalystSignedDocumentProvider, + validator::utils::validate_provided_doc, CatalystSignedDocument, +}; + +/// `category_id` field validation rule +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum CategoryRule { + /// Is `category_id` specified + Specified { + /// optional flag for the `category_id` field + optional: bool, + }, + /// `category_id` is not specified + NotSpecified, +} + +impl CategoryRule { + /// Field validation rule + pub(crate) async fn check( + &self, doc: &CatalystSignedDocument, provider: &Provider, + ) -> anyhow::Result + where Provider: 'static + CatalystSignedDocumentProvider { + if let Self::Specified { optional } = self { + if let Some(category) = &doc.doc_meta().category_id() { + let category_validator = |category_doc: CatalystSignedDocument| { + if category_doc.doc_type()?.uuid() != CATEGORY_DOCUMENT_UUID_TYPE { + doc.report().invalid_value( + "category_id", + category_doc.doc_type()?.to_string().as_str(), + CATEGORY_DOCUMENT_UUID_TYPE.to_string().as_str(), + "Invalid referenced category document type", + ); + return Ok(false); + } + Ok(true) + }; + return validate_provided_doc(category, provider, doc.report(), category_validator) + .await; + } else if !optional { + doc.report() + .missing_field("category_id", "Document must have a category field"); + return Ok(false); + } + } + if &Self::NotSpecified == self { + if let Some(category) = doc.doc_meta().category_id() { + doc.report().unknown_field( + "category_id", + &category.to_string(), + "Document does not expect to have a category field", + ); + return Ok(false); + } + } + + Ok(true) + } +} diff --git a/rust/signed_doc/src/validator/rules/content_encoding.rs b/rust/signed_doc/src/validator/rules/content_encoding.rs new file mode 100644 index 0000000000..734fc931f5 --- /dev/null +++ b/rust/signed_doc/src/validator/rules/content_encoding.rs @@ -0,0 +1,36 @@ +//! `content-encoding` rule type impl. + +use crate::{metadata::ContentEncoding, CatalystSignedDocument}; + +/// `content-encoding` field validation rule +pub(crate) struct ContentEncodingRule { + /// expected `content-encoding` field + pub(crate) exp: ContentEncoding, + /// optional flag for the `content-encoding` field + pub(crate) optional: bool, +} + +impl ContentEncodingRule { + /// Field validation rule + #[allow(clippy::unused_async)] + pub(crate) async fn check(&self, doc: &CatalystSignedDocument) -> anyhow::Result { + if let Some(content_encoding) = doc.doc_content_encoding() { + if content_encoding != self.exp { + doc.report().invalid_value( + "content-encoding", + content_encoding.to_string().as_str(), + self.exp.to_string().as_str(), + "Invalid Document content-encoding value", + ); + return Ok(false); + } + } else if !self.optional { + doc.report().missing_field( + "content-encoding", + "Document must have a content-encoding field", + ); + return Ok(false); + } + Ok(true) + } +} diff --git a/rust/signed_doc/src/validator/rules/content_type.rs b/rust/signed_doc/src/validator/rules/content_type.rs new file mode 100644 index 0000000000..5c993eb794 --- /dev/null +++ b/rust/signed_doc/src/validator/rules/content_type.rs @@ -0,0 +1,28 @@ +//! `content-type` rule type impl. + +use crate::{metadata::ContentType, CatalystSignedDocument}; + +/// `content-type` field validation rule +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ContentTypeRule { + /// expected `content-type` field + pub(crate) exp: ContentType, +} + +impl ContentTypeRule { + /// Field validation rule + #[allow(clippy::unused_async)] + pub(crate) async fn check(&self, doc: &CatalystSignedDocument) -> anyhow::Result { + let content_type = doc.doc_content_type()?; + if content_type != self.exp { + doc.report().invalid_value( + "content-type", + content_type.to_string().as_str(), + self.exp.to_string().as_str(), + "Invalid Document content-type value", + ); + return Ok(false); + } + Ok(true) + } +} diff --git a/rust/signed_doc/src/validator/rules/doc_ref.rs b/rust/signed_doc/src/validator/rules/doc_ref.rs new file mode 100644 index 0000000000..1f5a406d9a --- /dev/null +++ b/rust/signed_doc/src/validator/rules/doc_ref.rs @@ -0,0 +1,68 @@ +//! `ref` rule type impl. + +use catalyst_types::uuid::Uuid; + +use crate::{ + providers::CatalystSignedDocumentProvider, validator::utils::validate_provided_doc, + CatalystSignedDocument, +}; + +/// `ref` field validation rule +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum RefRule { + /// Is 'ref' specified + Specified { + /// expected `type` field of the referenced doc + exp_ref_type: Uuid, + /// optional flag for the `ref` field + optional: bool, + }, + /// 'ref' is not specified + NotSpecified, +} +impl RefRule { + /// Field validation rule + pub(crate) async fn check( + &self, doc: &CatalystSignedDocument, provider: &Provider, + ) -> anyhow::Result + where Provider: 'static + CatalystSignedDocumentProvider { + if let Self::Specified { + exp_ref_type, + optional, + } = self + { + if let Some(doc_ref) = doc.doc_meta().doc_ref() { + let ref_validator = |ref_doc: CatalystSignedDocument| { + if &ref_doc.doc_type()?.uuid() != exp_ref_type { + doc.report().invalid_value( + "ref", + ref_doc.doc_type()?.to_string().as_str(), + exp_ref_type.to_string().as_str(), + "Invalid referenced document type", + ); + return Ok(false); + } + Ok(true) + }; + return validate_provided_doc(&doc_ref, provider, doc.report(), ref_validator) + .await; + } else if !optional { + doc.report() + .missing_field("ref", "Document must have a ref field"); + return Ok(false); + } + } + if &Self::NotSpecified == self { + if let Some(doc_ref) = doc.doc_meta().doc_ref() { + doc.report().unknown_field( + "ref", + &doc_ref.to_string(), + "Document does not expect to have a ref field", + ); + return Ok(false); + } + } + + Ok(true) + } +} diff --git a/rust/signed_doc/src/validator/rules/mod.rs b/rust/signed_doc/src/validator/rules/mod.rs new file mode 100644 index 0000000000..81ebb39b27 --- /dev/null +++ b/rust/signed_doc/src/validator/rules/mod.rs @@ -0,0 +1,66 @@ +//! A list of validation rules for all metadata fields +//! + +use futures::FutureExt; + +use crate::{providers::CatalystSignedDocumentProvider, CatalystSignedDocument}; + +mod category; +mod content_encoding; +mod content_type; +mod doc_ref; +mod reply; +mod section; +mod template; + +pub(crate) use category::CategoryRule; +pub(crate) use content_encoding::ContentEncodingRule; +pub(crate) use content_type::ContentTypeRule; +pub(crate) use doc_ref::RefRule; +pub(crate) use reply::ReplyRule; +pub(crate) use section::SectionRule; +pub(crate) use template::TemplateRule; + +/// Struct represented a full collection of rules for all fields +pub(crate) struct Rules { + /// 'content-type' field validation rule + pub(crate) content_type: ContentTypeRule, + /// 'content-encoding' field validation rule + pub(crate) content_encoding: ContentEncodingRule, + /// 'ref' field validation rule + pub(crate) doc_ref: RefRule, + /// 'template' field validation rule + pub(crate) template: TemplateRule, + /// 'reply' field validation rule + pub(crate) reply: ReplyRule, + /// 'section' field validation rule + pub(crate) section: SectionRule, + /// 'category' field validation rule + pub(crate) category: CategoryRule, +} + +impl Rules { + /// All field validation rules check + pub(crate) async fn check( + &self, doc: &CatalystSignedDocument, provider: &Provider, + ) -> anyhow::Result + where Provider: 'static + CatalystSignedDocumentProvider { + let rules = [ + self.content_type.check(doc).boxed(), + self.content_encoding.check(doc).boxed(), + self.doc_ref.check(doc, provider).boxed(), + self.template.check(doc, provider).boxed(), + self.reply.check(doc, provider).boxed(), + self.section.check(doc).boxed(), + self.category.check(doc, provider).boxed(), + ]; + + let res = futures::future::join_all(rules) + .await + .into_iter() + .collect::>>()? + .iter() + .all(|res| *res); + Ok(res) + } +} diff --git a/rust/signed_doc/src/validator/rules/reply.rs b/rust/signed_doc/src/validator/rules/reply.rs new file mode 100644 index 0000000000..f9cb5d2248 --- /dev/null +++ b/rust/signed_doc/src/validator/rules/reply.rs @@ -0,0 +1,86 @@ +//! `reply` rule type impl. + +use catalyst_types::uuid::Uuid; + +use crate::{ + providers::CatalystSignedDocumentProvider, validator::utils::validate_provided_doc, + CatalystSignedDocument, +}; + +/// `reply` field validation rule +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum ReplyRule { + /// Is 'reply' specified + Specified { + /// expected `type` field of the replied doc + exp_reply_type: Uuid, + /// optional flag for the `ref` field + optional: bool, + }, + /// 'reply' is not specified + NotSpecified, +} + +impl ReplyRule { + /// Field validation rule + pub(crate) async fn check( + &self, doc: &CatalystSignedDocument, provider: &Provider, + ) -> anyhow::Result + where Provider: 'static + CatalystSignedDocumentProvider { + if let Self::Specified { + exp_reply_type, + optional, + } = self + { + if let Some(reply) = doc.doc_meta().reply() { + let reply_validator = |replied_doc: CatalystSignedDocument| { + if &replied_doc.doc_type()?.uuid() != exp_reply_type { + doc.report().invalid_value( + "reply", + replied_doc.doc_type()?.to_string().as_str(), + exp_reply_type.to_string().as_str(), + "Invalid referenced comment document type", + ); + return Ok(false); + } + let Some(replied_doc_ref) = replied_doc.doc_meta().doc_ref() else { + doc.report() + .missing_field("ref", "Invalid referenced comment document"); + return Ok(false); + }; + + if let Some(doc_ref) = doc.doc_meta().doc_ref() { + if replied_doc_ref.id != doc_ref.id { + doc.report().invalid_value( + "reply", + doc_ref.id .to_string().as_str(), + replied_doc_ref.id.to_string().as_str(), + "Invalid referenced comment document. Document ID should aligned with the replied comment.", + ); + return Ok(false); + } + } + Ok(true) + }; + return validate_provided_doc(&reply, provider, doc.report(), reply_validator) + .await; + } else if !optional { + doc.report() + .missing_field("reply", "Document must have a reply field"); + return Ok(false); + } + } + if let Self::NotSpecified = self { + if let Some(reply) = doc.doc_meta().reply() { + doc.report().unknown_field( + "reply", + &reply.to_string(), + "Document does not expect to have a reply field", + ); + return Ok(false); + } + } + + Ok(true) + } +} diff --git a/rust/signed_doc/src/validator/rules/section.rs b/rust/signed_doc/src/validator/rules/section.rs new file mode 100644 index 0000000000..79849eb61f --- /dev/null +++ b/rust/signed_doc/src/validator/rules/section.rs @@ -0,0 +1,40 @@ +//! `section` rule type impl. + +use crate::CatalystSignedDocument; + +/// `section` field validation rule +pub(crate) enum SectionRule { + /// Is 'section' specified + Specified { + /// optional flag for the `section` field + optional: bool, + }, + /// 'section' is not specified + NotSpecified, +} + +impl SectionRule { + /// Field validation rule + #[allow(clippy::unused_async)] + pub(crate) async fn check(&self, doc: &CatalystSignedDocument) -> anyhow::Result { + if let Self::Specified { optional } = self { + if doc.doc_meta().section().is_none() && !optional { + doc.report() + .missing_field("section", "Document must have a section field"); + return Ok(false); + } + } + if let Self::NotSpecified = self { + if let Some(section) = doc.doc_meta().section() { + doc.report().unknown_field( + "section", + §ion.to_string(), + "Document does not expect to have a section field", + ); + return Ok(false); + } + } + + Ok(true) + } +} diff --git a/rust/signed_doc/src/validator/rules/template.rs b/rust/signed_doc/src/validator/rules/template.rs new file mode 100644 index 0000000000..ded33e0422 --- /dev/null +++ b/rust/signed_doc/src/validator/rules/template.rs @@ -0,0 +1,117 @@ +//! `template` rule type impl. + +use catalyst_types::uuid::Uuid; + +use crate::{ + metadata::ContentType, providers::CatalystSignedDocumentProvider, + validator::utils::validate_provided_doc, CatalystSignedDocument, +}; + +/// `template` field validation rule +pub(crate) enum TemplateRule { + /// Is 'template' specified + Specified { + /// expected `type` field of the template + exp_template_type: Uuid, + }, + /// 'template' is not specified + #[allow(dead_code)] + NotSpecified, +} + +impl TemplateRule { + /// Field validation rule + pub(crate) async fn check( + &self, doc: &CatalystSignedDocument, provider: &Provider, + ) -> anyhow::Result + where Provider: 'static + CatalystSignedDocumentProvider { + if let Self::Specified { exp_template_type } = self { + let Some(template_ref) = doc.doc_meta().template() else { + doc.report() + .missing_field("template", "Document must have a template field"); + return Ok(false); + }; + + let template_validator = |template_doc: CatalystSignedDocument| { + if &template_doc.doc_type()?.uuid() != exp_template_type { + doc.report().invalid_value( + "template", + template_doc.doc_type()?.to_string().as_str(), + exp_template_type.to_string().as_str(), + "Invalid referenced template document type", + ); + return Ok(false); + } + match doc.doc_content_type()? { + ContentType::Json => json_schema_check(doc, &template_doc), + ContentType::Cbor => { + // TODO: not implemented yet + Ok(true) + }, + } + }; + return validate_provided_doc( + &template_ref, + provider, + doc.report(), + template_validator, + ) + .await; + } + if let Self::NotSpecified = self { + if let Some(template) = doc.doc_meta().template() { + doc.report().unknown_field( + "template", + &template.to_string(), + "Document does not expect to have a template field", + ); + return Ok(false); + } + } + + Ok(true) + } +} + +/// Validate a provided `doc` against the `template` content's Json schema, assuming that +/// the `doc` content is JSON. +fn json_schema_check( + doc: &CatalystSignedDocument, template_doc: &CatalystSignedDocument, +) -> anyhow::Result { + let Ok(template_json_schema) = + serde_json::from_slice(template_doc.doc_content().decoded_bytes()?) + else { + doc.report().functional_validation( + "Template document content must be json encoded", + "Invalid referenced template document content", + ); + return Ok(false); + }; + let Ok(schema_validator) = jsonschema::options() + .with_draft(jsonschema::Draft::Draft7) + .build(&template_json_schema) + else { + doc.report().functional_validation( + "Template document content must be Draft 7 JSON schema", + "Invalid referenced template document content", + ); + return Ok(false); + }; + + let Ok(doc_json) = serde_json::from_slice(doc.doc_content().decoded_bytes()?) else { + doc.report().functional_validation( + "Document content must be json encoded", + "Invalid referenced template document content", + ); + return Ok(false); + }; + + if schema_validator.validate(&doc_json).is_err() { + doc.report().functional_validation( + "Proposal document content does not compliant with the template json schema", + "Invalid Proposal document content", + ); + return Ok(false); + } + Ok(true) +} diff --git a/rust/signed_doc/src/validator/utils.rs b/rust/signed_doc/src/validator/utils.rs new file mode 100644 index 0000000000..a6464bbc4c --- /dev/null +++ b/rust/signed_doc/src/validator/utils.rs @@ -0,0 +1,25 @@ +//! Validation utility functions + +use catalyst_types::problem_report::ProblemReport; + +use crate::{providers::CatalystSignedDocumentProvider, CatalystSignedDocument, DocumentRef}; + +/// A helper validation document function, which validates a document from the +/// `ValidationDataProvider`. +pub(crate) async fn validate_provided_doc( + doc_ref: &DocumentRef, provider: &Provider, report: &ProblemReport, validator: Validator, +) -> anyhow::Result +where + Provider: 'static + CatalystSignedDocumentProvider, + Validator: Fn(CatalystSignedDocument) -> anyhow::Result, +{ + if let Some(doc) = provider.try_get_doc(doc_ref).await? { + validator(doc) + } else { + report.functional_validation( + format!("Cannot retrieve a document {doc_ref}").as_str(), + "Validation data provider could not return a corresponding {doc_name}.", + ); + Ok(false) + } +}