Skip to content

feat(rust/signed-doc): Catalyst Signed Documents minicbor::Decode impl #383

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 32 commits into from
Jun 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions rust/catalyst-types/src/catalyst_id/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -676,12 +676,9 @@ impl TryFrom<&[u8]> for CatalystId {
}
}

impl minicbor::Encode<()> for CatalystId {
fn encode<W: minicbor::encode::Write>(
&self, e: &mut minicbor::Encoder<W>, _ctx: &mut (),
) -> Result<(), minicbor::encode::Error<W::Error>> {
e.bytes(self.to_string().into_bytes().as_slice())?;
Ok(())
impl From<&CatalystId> for Vec<u8> {
fn from(value: &CatalystId) -> Self {
value.to_string().into_bytes()
}
}

Expand Down
29 changes: 19 additions & 10 deletions rust/cbork-utils/src/deterministic_helper.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,11 @@ impl Ord for MapEntry {
/// - Map keys are not properly sorted (`UnorderedMapKeys`)
/// - Duplicate keys are found (`DuplicateMapKey`)
/// - Map key or value decoding fails (`DecoderError`)
pub fn decode_map_deterministically(d: &mut Decoder) -> Result<Vec<u8>, minicbor::decode::Error> {
pub fn decode_map_deterministically(
d: &mut Decoder,
) -> Result<Vec<MapEntry>, minicbor::decode::Error> {
validate_input_not_empty(d)?;

// Store the starting position BEFORE consuming the map header
let map_start = d.position();

// From RFC 8949 Section 4.2.2:
// "Indefinite-length items must be made definite-length items."
// The specification explicitly prohibits indefinite-length items in
Expand All @@ -115,18 +114,14 @@ pub fn decode_map_deterministically(d: &mut Decoder) -> Result<Vec<u8>, minicbor
})?;

let header_end_pos = d.position();

check_map_minimal_length(d, header_end_pos, map_len)?;

// Decode entries to validate them
let entries = decode_map_entries(d, map_len)?;

validate_map_ordering(&entries)?;

// Get the ending position after validation
let map_end = d.position();

get_bytes(d, map_start, map_end)
Ok(entries)
}

/// Extracts the raw bytes of a CBOR map from a decoder based on specified positions.
Expand Down Expand Up @@ -501,7 +496,21 @@ mod tests {
let result = decode_map_deterministically(&mut decoder).unwrap();

// Verify we got back exactly the same bytes
assert_eq!(result, valid_map);

assert_eq!(result, vec![
MapEntry {
// Key 1: 2-byte string
key_bytes: vec![0x42, 0x01, 0x02],
// Value 1: 1-byte string
value: vec![0x41, 0x01]
},
MapEntry {
// Key 2: 3-byte string
key_bytes: vec![0x43, 0x01, 0x02, 0x03,],
// Value 2: 1-byte string
value: vec![0x41, 0x02,]
}
]);
}

/// Test cases for lexicographic ordering of map keys as specified in RFC 8949 Section
Expand Down
2 changes: 1 addition & 1 deletion rust/signed_doc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ workspace = true

[dependencies]
catalyst-types = { version = "0.0.3", path = "../catalyst-types" }
cbork-utils = { version = "0.0.1", path = "../cbork-utils" }

anyhow = "1.0.95"
serde = { version = "1.0.217", features = ["derive"] }
serde_json = { version = "1.0.134", features = ["raw_value"] }
coset = "0.3.8"
minicbor = { version = "0.25.1", features = ["half"] }
brotli = "7.0.0"
ed25519-dalek = { version = "2.1.1", features = ["rand_core", "pem"] }
Expand Down
15 changes: 15 additions & 0 deletions rust/signed_doc/src/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,18 @@ impl minicbor::Encode<()> for Content {
Ok(())
}
}

impl minicbor::Decode<'_, ()> for Content {
fn decode(
d: &mut minicbor::Decoder<'_>, _ctx: &mut (),
) -> Result<Self, minicbor::decode::Error> {
let p = d.position();
d.null()
.map(|()| Self(Vec::new()))
// important to use `or_else` so it will lazy evaluated at the time when it is needed
.or_else(|_| {
d.set_position(p);
d.bytes().map(Vec::from).map(Self)
})
}
}
66 changes: 41 additions & 25 deletions rust/signed_doc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ pub use catalyst_types::{
uuid::{Uuid, UuidV4, UuidV7},
};
pub use content::Content;
use coset::{CborSerializable, TaggedCborSerializable};
use decode_context::{CompatibilityPolicy, DecodeContext};
pub use metadata::{
ContentEncoding, ContentType, DocLocator, DocType, DocumentRef, DocumentRefs, Metadata, Section,
Expand All @@ -31,8 +30,8 @@ pub use signature::{CatalystId, Signatures};

use crate::builder::SignaturesBuilder;

/// A problem report content string
const PROBLEM_REPORT_CTX: &str = "Catalyst Signed Document";
/// `COSE_Sign` object CBOR tag <https://datatracker.ietf.org/doc/html/rfc8152#page-8>
const COSE_SIGN_CBOR_TAG: minicbor::data::Tag = minicbor::data::Tag::new(98);

/// Inner type that holds the Catalyst Signed Document with parsing errors.
#[derive(Debug)]
Expand Down Expand Up @@ -212,34 +211,51 @@ impl CatalystSignedDocument {

impl Decode<'_, ()> for CatalystSignedDocument {
fn decode(d: &mut Decoder<'_>, _ctx: &mut ()) -> Result<Self, decode::Error> {
let start = d.position();
d.skip()?;
let end = d.position();
let cose_bytes = d
.input()
.get(start..end)
.ok_or(minicbor::decode::Error::end_of_input())?;

let cose_sign = coset::CoseSign::from_tagged_slice(cose_bytes)
.or_else(|_| coset::CoseSign::from_slice(cose_bytes))
.map_err(|e| {
minicbor::decode::Error::message(format!("Invalid COSE Sign document: {e}"))
})?;

let mut report = ProblemReport::new(PROBLEM_REPORT_CTX);
let mut report = ProblemReport::new("Catalyst Signed Document Decoding");
let mut ctx = DecodeContext {
compatibility_policy: CompatibilityPolicy::Accept,
report: &mut report,
};
let metadata = Metadata::from_protected_header(&cose_sign.protected, &mut ctx);
let signatures = Signatures::from_cose_sig_list(&cose_sign.signatures, &report);
let start = d.position();

let content = if let Some(payload) = cose_sign.payload {
payload.into()
if let Ok(tag) = d.tag() {
if tag != COSE_SIGN_CBOR_TAG {
return Err(minicbor::decode::Error::message(format!(
"Must be equal to the COSE_Sign tag value: {COSE_SIGN_CBOR_TAG}"
)));
}
} else {
report.missing_field("COSE Sign Payload", "Missing document content (payload)");
Content::default()
};
d.set_position(start);
}

if !matches!(d.array()?, Some(4)) {
return Err(minicbor::decode::Error::message(
"Must be a definite size array of 4 elements",
));
}

let metadata_bytes = d.bytes()?;
let metadata = Metadata::decode(&mut minicbor::Decoder::new(metadata_bytes), &mut ctx)?;

// empty unprotected headers
let mut map =
cbork_utils::deterministic_helper::decode_map_deterministically(d)?.into_iter();
if map.next().is_some() {
ctx.report.unknown_field(
"unprotected headers",
"non empty unprotected headers",
"COSE unprotected headers must be empty",
);
}

let content = Content::decode(d, &mut ())?;
let signatures = Signatures::decode(d, &mut ctx)?;

let end = d.position();
let cose_bytes = d
.input()
.get(start..end)
.ok_or(minicbor::decode::Error::end_of_input())?;

Ok(InnerCatalystSignedDocument {
metadata,
Expand Down
57 changes: 57 additions & 0 deletions rust/signed_doc/src/metadata/collaborators.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! Catalyst Signed Document `collabs` field type definition.

use std::ops::Deref;

/// 'collabs' field type definition, which is a JSON path string
#[derive(Clone, Debug, PartialEq)]
pub struct Collaborators(Vec<String>);

impl Deref for Collaborators {
type Target = Vec<String>;

fn deref(&self) -> &Self::Target {
&self.0
}
}

impl minicbor::Encode<()> for Collaborators {
fn encode<W: minicbor::encode::Write>(
&self, e: &mut minicbor::Encoder<W>, _ctx: &mut (),
) -> Result<(), minicbor::encode::Error<W::Error>> {
if !self.0.is_empty() {
e.array(
self.0
.len()
.try_into()
.map_err(minicbor::encode::Error::message)?,
)?;
for c in &self.0 {
e.str(c)?;
}
}
Ok(())
}
}

impl minicbor::Decode<'_, ()> for Collaborators {
fn decode(
d: &mut minicbor::Decoder<'_>, _ctx: &mut (),
) -> Result<Self, minicbor::decode::Error> {
let Some(items) = d.array()? else {
return Err(minicbor::decode::Error::message(
"Must a definite size array",
));
};
let collabs = (0..items)
.map(|_| Ok(d.str()?.to_string()))
.collect::<Result<_, _>>()?;
Ok(Self(collabs))
}
}

impl<'de> serde::Deserialize<'de> for Collaborators {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where D: serde::Deserializer<'de> {
Ok(Self(Vec::<String>::deserialize(deserializer)?))
}
}
21 changes: 8 additions & 13 deletions rust/signed_doc/src/metadata/content_encoding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,6 @@ impl<'de> Deserialize<'de> for ContentEncoding {
}
}

impl TryFrom<&coset::cbor::Value> for ContentEncoding {
type Error = anyhow::Error;

fn try_from(val: &coset::cbor::Value) -> anyhow::Result<ContentEncoding> {
match val.as_text() {
Some(encoding) => encoding.parse(),
None => {
anyhow::bail!("Expected Content Encoding to be a string");
},
}
}
}

impl minicbor::Encode<()> for ContentEncoding {
fn encode<W: minicbor::encode::Write>(
&self, e: &mut minicbor::Encoder<W>, _ctx: &mut (),
Expand All @@ -93,3 +80,11 @@ impl minicbor::Encode<()> for ContentEncoding {
Ok(())
}
}

impl minicbor::Decode<'_, ()> for ContentEncoding {
fn decode(
d: &mut minicbor::Decoder<'_>, _ctx: &mut (),
) -> Result<Self, minicbor::decode::Error> {
d.str()?.parse().map_err(minicbor::decode::Error::message)
}
}
45 changes: 23 additions & 22 deletions rust/signed_doc/src/metadata/content_type.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ use std::{
str::FromStr,
};

use coset::iana::CoapContentFormat;
use serde::{de, Deserialize, Deserializer};
use strum::VariantArray;

Expand Down Expand Up @@ -55,27 +54,6 @@ impl<'de> Deserialize<'de> for ContentType {
}
}

impl TryFrom<&coset::ContentType> for ContentType {
type Error = anyhow::Error;

fn try_from(value: &coset::ContentType) -> Result<Self, Self::Error> {
match value {
coset::ContentType::Assigned(CoapContentFormat::Json) => Ok(ContentType::Json),
coset::ContentType::Assigned(CoapContentFormat::Cbor) => Ok(ContentType::Cbor),
coset::ContentType::Text(str) => str.parse(),
coset::RegisteredLabel::Assigned(_) => {
anyhow::bail!(
"Unsupported Content Type: {value:?}, Supported only: {:?}",
ContentType::VARIANTS
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
)
},
}
}
}

impl minicbor::Encode<()> for ContentType {
fn encode<W: minicbor::encode::Write>(
&self, e: &mut minicbor::Encoder<W>, _ctx: &mut (),
Expand All @@ -86,6 +64,29 @@ impl minicbor::Encode<()> for ContentType {
}
}

impl minicbor::Decode<'_, ()> for ContentType {
fn decode(
d: &mut minicbor::Decoder<'_>, _ctx: &mut (),
) -> Result<Self, minicbor::decode::Error> {
let p = d.position();
match d.int() {
// CoAP Content Format JSON
Ok(val) if val == minicbor::data::Int::from(50_u8) => Ok(Self::Json),
// CoAP Content Format CBOR
Ok(val) if val == minicbor::data::Int::from(60_u8) => Ok(Self::Cbor),
Ok(val) => {
Err(minicbor::decode::Error::message(format!(
"unsupported CoAP Content Formats value: {val}"
)))
},
Err(_) => {
d.set_position(p);
d.str()?.parse().map_err(minicbor::decode::Error::message)
},
}
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
Loading