From 96846f00e353915ba2537d4bf9fabf5459d93f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20Rosales?= Date: Sun, 15 Dec 2024 21:02:59 -0600 Subject: [PATCH] wip(rust/signed_doc): Impl Display for CatalystSignedDocument, add inspect example --- rust/signed_doc/examples/cat-signed-doc.rs | 54 +++++ rust/signed_doc/src/lib.rs | 229 ++++++++++++++++++++- 2 files changed, 275 insertions(+), 8 deletions(-) create mode 100644 rust/signed_doc/examples/cat-signed-doc.rs diff --git a/rust/signed_doc/examples/cat-signed-doc.rs b/rust/signed_doc/examples/cat-signed-doc.rs new file mode 100644 index 000000000..dce556657 --- /dev/null +++ b/rust/signed_doc/examples/cat-signed-doc.rs @@ -0,0 +1,54 @@ +//! Inspect a Catalyst Signed Document. +use std::{ + fs::{ + // read_to_string, + File, + }, + io::{ + Read, + // Write + }, + path::PathBuf, +}; + +use clap::Parser; +use signed_doc::CatalystSignedDocument; + +/// Hermes cli commands +#[derive(clap::Parser)] +enum Cli { + /// Inspects COSE document + Inspect { + /// Path to the fully formed (should has at least one signature) COSE document + cose_sign: PathBuf, + /// Path to the json schema (Draft 7) to validate document against it + doc_schema: PathBuf, + }, +} + +impl Cli { + /// Execute Cli command + fn exec(self) -> anyhow::Result<()> { + match self { + Self::Inspect { + cose_sign, + doc_schema: _, + } => { + // + let mut cose_file = File::open(cose_sign)?; + let mut cose_file_bytes = Vec::new(); + cose_file.read_to_end(&mut cose_file_bytes)?; + let cat_signed_doc: CatalystSignedDocument = cose_file_bytes.try_into()?; + println!("{cat_signed_doc}"); + Ok(()) + }, + } + } +} + +fn main() { + println!("Catalyst Signed Document"); + if let Err(err) = Cli::parse().exec() { + println!("{err}"); + } +} diff --git a/rust/signed_doc/src/lib.rs b/rust/signed_doc/src/lib.rs index 469fcbce4..72b943eb3 100644 --- a/rust/signed_doc/src/lib.rs +++ b/rust/signed_doc/src/lib.rs @@ -1,5 +1,12 @@ //! Catalyst documents signing crate -use std::{convert::TryFrom, sync::Arc}; +#![allow(dead_code)] +use std::{ + convert::TryFrom, + fmt::{Display, Formatter}, + sync::Arc, +}; + +use coset::CborSerializable; /// Keep all the contents private. /// Better even to use a structure like this. Wrapping in an Arc means we don't have to @@ -12,12 +19,26 @@ pub struct CatalystSignedDocument { content_errors: Vec, } +impl Display for CatalystSignedDocument { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + writeln!(f, "Metadata: {:?}", self.inner.metadata)?; + writeln!(f, "JSON Payload: {}", self.inner.payload)?; + writeln!(f, "Signatures: {:?}", self.inner.signatures)?; + write!(f, "Content Errors: {:?}", self.content_errors) + } +} + +#[derive(Default)] /// Inner type that holds the Catalyst Signed Document with parsing errors. struct InnerCatalystSignedDocument { /// Document Metadata metadata: Metadata, - /// Raw payload - _raw_doc: Vec, + /// JSON Payload + payload: serde_json::Value, + /// Signatures + signatures: Vec, + /// Raw COSE Sign bytes + cose_sign: coset::CoseSign, } /// Document Metadata. @@ -39,8 +60,22 @@ pub struct Metadata { pub section: Option, } +impl Default for Metadata { + fn default() -> Self { + Self { + r#type: CatalystSignedDocument::INVALID_UUID, + id: CatalystSignedDocument::INVALID_UUID, + ver: CatalystSignedDocument::INVALID_UUID, + r#ref: None, + template: None, + reply: None, + section: None, + } + } +} + /// Reference to a Document. -#[derive(Debug, serde::Deserialize)] +#[derive(Copy, Clone, Debug, serde::Deserialize)] #[serde(untagged)] pub enum DocumentRef { /// Reference to the latest document @@ -62,17 +97,39 @@ pub enum DocumentRef { // multiple parameters to actually create the type. This is much more elegant to use this // way, in code. impl TryFrom> for CatalystSignedDocument { - type Error = &'static str; + type Error = anyhow::Error; #[allow(clippy::todo)] - fn try_from(_value: Vec) -> Result { - todo!(); + fn try_from(cose_bytes: Vec) -> Result { + let cose = coset::CoseSign::from_slice(&cose_bytes) + .map_err(|e| anyhow::anyhow!("Invalid COSE Sign document: {e}"))?; + let payload = match &cose.payload { + Some(payload) => { + let mut buf = Vec::new(); + let mut bytes = payload.as_slice(); + brotli::BrotliDecompress(&mut bytes, &mut buf)?; + serde_json::from_slice(&buf)? + }, + None => { + println!("COSE missing payload field with the JSON content in it"); + serde_json::Value::Object(serde_json::Map::new()) + }, + }; + let inner = InnerCatalystSignedDocument { + cose_sign: cose, + payload, + ..Default::default() + }; + Ok(CatalystSignedDocument { + inner: Arc::new(inner), + content_errors: Vec::new(), + }) } } impl CatalystSignedDocument { /// Invalid Doc Type UUID - const _INVALID_UUID: uuid::Uuid = uuid::Uuid::from_bytes([0x00; 16]); + const INVALID_UUID: uuid::Uuid = uuid::Uuid::from_bytes([0x00; 16]); // A bunch of getters to access the contents, or reason through the document, such as. @@ -94,3 +151,159 @@ impl CatalystSignedDocument { self.inner.metadata.id } } + +/// Catalyst Signed Document Content Encoding Key. +const CONTENT_ENCODING_KEY: &str = "content encoding"; +/// Catalyst Signed Document Content Encoding Value. +const CONTENT_ENCODING_VALUE: &str = "br"; +/// CBOR tag for UUID content. +const UUID_CBOR_TAG: u64 = 37; + +/// Generate the COSE protected header used by Catalyst Signed Document. +fn cose_protected_header() -> coset::Header { + coset::HeaderBuilder::new() + .algorithm(coset::iana::Algorithm::EdDSA) + .content_format(coset::iana::CoapContentFormat::Json) + .text_value( + CONTENT_ENCODING_KEY.to_string(), + CONTENT_ENCODING_VALUE.to_string().into(), + ) + .build() +} + +/// Decode `CBOR` encoded `UUID`. +fn decode_cbor_uuid(val: &coset::cbor::Value) -> anyhow::Result { + let Some((UUID_CBOR_TAG, coset::cbor::Value::Bytes(bytes))) = val.as_tag() else { + anyhow::bail!("Invalid CBOR encoded UUID type"); + }; + let uuid = uuid::Uuid::from_bytes( + bytes + .clone() + .try_into() + .map_err(|_| anyhow::anyhow!("Invalid CBOR encoded UUID type, invalid bytes size"))?, + ); + Ok(uuid) +} + +/// Decode `CBOR` encoded `DocumentRef`. +#[allow(clippy::indexing_slicing)] +fn decode_cbor_document_ref(val: &coset::cbor::Value) -> anyhow::Result { + if let Ok(id) = decode_cbor_uuid(val) { + Ok(DocumentRef::Latest { id }) + } else { + let Some(array) = val.as_array() else { + anyhow::bail!("Invalid CBOR encoded document `ref` type"); + }; + anyhow::ensure!(array.len() == 2, "Invalid CBOR encoded document `ref` type"); + let id = decode_cbor_uuid(&array[0])?; + let ver = decode_cbor_uuid(&array[1])?; + Ok(DocumentRef::WithVer { id, ver }) + } +} + +/// Extract `Metadata` from `coset::CoseSign`. +fn validate_cose_protected_header(cose: &coset::CoseSign) -> anyhow::Result { + let expected_header = cose_protected_header(); + anyhow::ensure!( + cose.protected.header.alg == expected_header.alg, + "Invalid COSE document protected header `algorithm` field" + ); + anyhow::ensure!( + cose.protected.header.content_type == expected_header.content_type, + "Invalid COSE document protected header `content-type` field" + ); + anyhow::ensure!( + cose.protected.header.rest.iter().any(|(key, value)| { + key == &coset::Label::Text(CONTENT_ENCODING_KEY.to_string()) + && value == &coset::cbor::Value::Text(CONTENT_ENCODING_VALUE.to_string()) + }), + "Invalid COSE document protected header {CONTENT_ENCODING_KEY} field" + ); + let mut metadata = Metadata::default(); + + let Some((_, value)) = cose + .protected + .header + .rest + .iter() + .find(|(key, _)| key == &coset::Label::Text("type".to_string())) + else { + anyhow::bail!("Invalid COSE protected header, missing `type` field"); + }; + metadata.r#type = decode_cbor_uuid(value) + .map_err(|e| anyhow::anyhow!("Invalid COSE protected header `type` field, err: {e}"))?; + + let Some((_, value)) = cose + .protected + .header + .rest + .iter() + .find(|(key, _)| key == &coset::Label::Text("id".to_string())) + else { + anyhow::bail!("Invalid COSE protected header, missing `id` field"); + }; + decode_cbor_uuid(value) + .map_err(|e| anyhow::anyhow!("Invalid COSE protected header `id` field, err: {e}"))?; + + let Some((_, value)) = cose + .protected + .header + .rest + .iter() + .find(|(key, _)| key == &coset::Label::Text("ver".to_string())) + else { + anyhow::bail!("Invalid COSE protected header, missing `ver` field"); + }; + decode_cbor_uuid(value) + .map_err(|e| anyhow::anyhow!("Invalid COSE protected header `ver` field, err: {e}"))?; + + if let Some((_, value)) = cose + .protected + .header + .rest + .iter() + .find(|(key, _)| key == &coset::Label::Text("ref".to_string())) + { + decode_cbor_document_ref(value) + .map_err(|e| anyhow::anyhow!("Invalid COSE protected header `ref` field, err: {e}"))?; + } + + if let Some((_, value)) = cose + .protected + .header + .rest + .iter() + .find(|(key, _)| key == &coset::Label::Text("template".to_string())) + { + decode_cbor_document_ref(value).map_err(|e| { + anyhow::anyhow!("Invalid COSE protected header `template` field, err: {e}") + })?; + } + + if let Some((_, value)) = cose + .protected + .header + .rest + .iter() + .find(|(key, _)| key == &coset::Label::Text("reply".to_string())) + { + decode_cbor_document_ref(value).map_err(|e| { + anyhow::anyhow!("Invalid COSE protected header `reply` field, err: {e}") + })?; + } + + if let Some((_, value)) = cose + .protected + .header + .rest + .iter() + .find(|(key, _)| key == &coset::Label::Text("section".to_string())) + { + anyhow::ensure!( + value.is_text(), + "Invalid COSE protected header, missing `section` field" + ); + } + + Ok(metadata) +}