diff --git a/rust/signed_doc/Cargo.toml b/rust/signed_doc/Cargo.toml index c4743942c3..8bbfb181e7 100644 --- a/rust/signed_doc/Cargo.toml +++ b/rust/signed_doc/Cargo.toml @@ -16,7 +16,7 @@ anyhow = "1.0.95" serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.134" coset = "0.3.8" -minicbor = "0.25.1" +minicbor = { version = "0.25.1", features = ["half"] } brotli = "7.0.0" ed25519-dalek = { version = "2.1.1", features = ["pem", "rand_core"] } uuid = { version = "1.11.0", features = ["v4", "v7", "serde"] } @@ -26,9 +26,10 @@ clap = { version = "4.5.23", features = ["derive", "env"] } [dev-dependencies] +base64-url = "3.0.0" rand = "0.8.5" [[bin]] name = "signed-docs" -path = "examples/mk_signed_doc.rs" \ No newline at end of file +path = "examples/mk_signed_doc.rs" diff --git a/rust/signed_doc/examples/mk_signed_doc.rs b/rust/signed_doc/examples/mk_signed_doc.rs index 4dfc0af6e0..25215c2a70 100644 --- a/rust/signed_doc/examples/mk_signed_doc.rs +++ b/rust/signed_doc/examples/mk_signed_doc.rs @@ -10,8 +10,7 @@ use std::{ use catalyst_signed_doc::{Builder, CatalystSignedDocument, KidUri, Metadata}; use clap::Parser; -use coset::CborSerializable; -use ed25519_dalek::{ed25519::signature::Signer, pkcs8::DecodePrivateKey}; +use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey}; fn main() { if let Err(err) = Cli::parse().exec() { @@ -52,6 +51,16 @@ 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: KidUri, + }, } impl Cli { @@ -68,32 +77,43 @@ impl Cli { let payload = serde_json::to_vec(&json_doc)?; // Start with no signatures. let signed_doc = Builder::new() - .with_content(payload) + .with_decoded_content(payload) .with_metadata(metadata) .build()?; - let mut bytes: Vec = Vec::new(); - minicbor::encode(signed_doc, &mut bytes) - .map_err(|e| anyhow::anyhow!("Failed to encode document: {e}"))?; - - write_bytes_to_file(&bytes, &output)?; + save_signed_doc(signed_doc, &output)?; }, Self::Sign { sk, doc, kid } => { let sk = load_secret_key_from_file(&sk) .map_err(|e| anyhow::anyhow!("Failed to load SK FILE: {e}"))?; - let mut cose = load_cose_from_file(&doc) - .map_err(|e| anyhow::anyhow!("Failed to load COSE FROM FILE: {e}"))?; - add_signature_to_cose(&mut cose, &sk, kid.to_string()); - store_cose_file(cose, &doc)?; + 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 new_signed_doc = builder.add_signature(sk.to_bytes(), kid)?.build()?; + save_signed_doc(new_signed_doc, &doc)?; }, Self::Inspect { path } => { - let mut cose_file = File::open(path)?; - let mut cose_bytes = Vec::new(); - cose_file.read_to_end(&mut cose_bytes)?; - decode_signed_doc(&cose_bytes); + let cose_bytes = read_bytes_from_file(&path)?; + inspect_signed_doc(&cose_bytes)?; }, Self::InspectBytes { cose_sign_hex } => { let cose_bytes = hex::decode(&cose_sign_hex)?; - decode_signed_doc(&cose_bytes); + 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() { + pk + } else { + k.role0_pk() + } + }) + .map_err(|e| anyhow::anyhow!("Catalyst Document Verification failed: {e}"))?; + println!("Catalyst Signed Document is Verified."); }, } println!("Done"); @@ -101,20 +121,36 @@ impl Cli { } } -fn decode_signed_doc(cose_bytes: &[u8]) { +fn read_bytes_from_file(path: &PathBuf) -> anyhow::Result> { + let mut cose_file = File::open(path)?; + let mut cose_bytes = Vec::new(); + cose_file.read_to_end(&mut cose_bytes)?; + Ok(cose_bytes) +} + +fn inspect_signed_doc(cose_bytes: &[u8]) -> anyhow::Result<()> { println!( - "Decoding {} bytes: {}", + "Decoding {} bytes:\n{}", cose_bytes.len(), hex::encode(cose_bytes) ); + let cat_signed_doc = signed_doc_from_bytes(cose_bytes)?; + println!("This is a valid Catalyst Document."); + println!("{cat_signed_doc}"); + Ok(()) +} - match CatalystSignedDocument::try_from(cose_bytes) { - Ok(cat_signed_doc) => { - println!("This is a valid Catalyst Document."); - println!("{cat_signed_doc}"); - }, - Err(e) => eprintln!("Invalid Catalyst Document, err: {e}"), - } +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}"))?; + + 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}")) } fn load_json_from_file(path: &PathBuf) -> anyhow::Result @@ -124,45 +160,20 @@ where T: for<'de> serde::Deserialize<'de> { Ok(json) } -fn load_cose_from_file(cose_path: &PathBuf) -> anyhow::Result { - let cose_file_bytes = read_bytes_from_file(cose_path)?; - let cose = coset::CoseSign::from_slice(&cose_file_bytes).map_err(|e| anyhow::anyhow!("{e}"))?; - Ok(cose) -} - -fn read_bytes_from_file(path: &PathBuf) -> anyhow::Result> { - let mut file_bytes = Vec::new(); - File::open(path)?.read_to_end(&mut file_bytes)?; - Ok(file_bytes) -} - 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}")) } -fn store_cose_file(cose: coset::CoseSign, output: &PathBuf) -> anyhow::Result<()> { - let cose_bytes = cose - .to_vec() - .map_err(|e| anyhow::anyhow!("Failed to Store COSE SIGN: {e}"))?; - write_bytes_to_file(&cose_bytes, output) -} - fn load_secret_key_from_file(sk_path: &PathBuf) -> anyhow::Result { let sk_str = read_to_string(sk_path)?; let sk = ed25519_dalek::SigningKey::from_pkcs8_pem(&sk_str)?; Ok(sk) } -fn add_signature_to_cose(cose: &mut coset::CoseSign, sk: &ed25519_dalek::SigningKey, kid: String) { - let protected_header = coset::HeaderBuilder::new() - .key_id(kid.into_bytes()) - .algorithm(coset::iana::Algorithm::EdDSA); - let mut signature = coset::CoseSignatureBuilder::new() - .protected(protected_header.build()) - .build(); - let data_to_sign = cose.tbs_data(&[], &signature); - signature.signature = sk.sign(&data_to_sign).to_vec(); - cose.signatures.push(signature); +fn load_public_key_from_file(pk_path: &PathBuf) -> 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 34ddcc1678..e16076d98c 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::kid_uri::KidUri; +use ed25519_dalek::{ed25519::signature::Signer, SecretKey}; + use crate::{CatalystSignedDocument, Content, InnerCatalystSignedDocument, Metadata, Signatures}; /// Catalyst Signed Document Builder. -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct Builder { /// Document Metadata metadata: Option, @@ -26,13 +29,58 @@ impl Builder { self } - /// Set document content + /// Set decoded (original) document content bytes #[must_use] - pub fn with_content(mut self, content: Vec) -> Self { + pub fn with_decoded_content(mut self, content: Vec) -> Self { self.content = Some(content); self } + /// Set document signatures + #[must_use] + pub fn with_signatures(mut self, signatures: Signatures) -> Self { + self.signatures = signatures; + self + } + + /// Add a signature to the document + /// + /// # Errors + /// + /// Fails if a `CatalystSignedDocument` cannot be created due to missing metadata or + /// content, due to malformed data, or when the signed document cannot be + /// converted into `coset::CoseSign`. + pub fn add_signature(self, sk: SecretKey, kid: KidUri) -> anyhow::Result { + let cose_sign = self + .clone() + .build() + .map_err(|e| anyhow::anyhow!("Failed to sign: {e}"))? + .as_cose_sign() + .map_err(|e| anyhow::anyhow!("Failed to sign: {e}"))?; + let Self { + metadata: Some(metadata), + content: Some(content), + mut signatures, + } = self + else { + anyhow::bail!("Metadata and Content are needed for signing"); + }; + 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()); + let mut signature = coset::CoseSignatureBuilder::new() + .protected(protected_header.build()) + .build(); + let data_to_sign = cose_sign.tbs_data(&[], &signature); + signature.signature = sk.sign(&data_to_sign).to_vec(); + signatures.push(kid, signature); + Ok(Self::new() + .with_decoded_content(content) + .with_metadata(metadata) + .with_signatures(signatures)) + } + /// Build a signed document /// /// ## Errors diff --git a/rust/signed_doc/src/content.rs b/rust/signed_doc/src/content.rs index 9b49fc53f1..92c644e158 100644 --- a/rust/signed_doc/src/content.rs +++ b/rust/signed_doc/src/content.rs @@ -28,6 +28,7 @@ impl Content { .decode(&data) .map_err(|e| anyhow::anyhow!("Failed to decode {encoding} content: {e}"))?; } + content_type.validate(&data)?; Ok(Self { data, @@ -41,11 +42,10 @@ impl Content { /// /// # Errors /// Returns an error if content is not correctly encoded - #[allow(clippy::unnecessary_wraps)] pub(crate) fn from_decoded( data: Vec, content_type: ContentType, content_encoding: Option, ) -> anyhow::Result { - // TODO add content_type verification + content_type.validate(&data)?; Ok(Self { data, content_type, @@ -87,13 +87,7 @@ impl Content { /// Return content byte size #[must_use] - pub fn len(&self) -> usize { + pub fn size(&self) -> usize { self.data.len() } - - /// Return `true` if content is empty - #[must_use] - pub fn is_empty(&self) -> bool { - self.data.is_empty() - } } diff --git a/rust/signed_doc/src/lib.rs b/rust/signed_doc/src/lib.rs index e65f5e8675..ff2bf290f0 100644 --- a/rust/signed_doc/src/lib.rs +++ b/rust/signed_doc/src/lib.rs @@ -17,6 +17,7 @@ pub use builder::Builder; use catalyst_types::problem_report::ProblemReport; pub use content::Content; use coset::{CborSerializable, Header}; +use ed25519_dalek::VerifyingKey; use error::CatalystSignedDocError; pub use metadata::{DocumentRef, ExtraFields, Metadata, UuidV4, UuidV7}; pub use minicbor::{decode, encode, Decode, Decoder, Encode}; @@ -46,7 +47,7 @@ pub struct CatalystSignedDocument { impl Display for CatalystSignedDocument { fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { writeln!(f, "{}", self.inner.metadata)?; - writeln!(f, "Payload Size: {} bytes", self.inner.content.len())?; + writeln!(f, "Payload Size: {} bytes", self.inner.content.size())?; writeln!(f, "Signature Information")?; if self.inner.signatures.is_empty() { writeln!(f, " This document is unsigned.")?; @@ -105,6 +106,82 @@ impl CatalystSignedDocument { pub fn signatures(&self) -> &Signatures { &self.inner.signatures } + + /// Verify document signatures. + /// + /// # 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(&KidUri) -> VerifyingKey { + 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.signatures().kids().iter().enumerate() { + let pk = pk_getter(kid); + 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", + ); + }, + } + } + }, + Err(e) => { + error_report.other( + &format!("{e}"), + "During encoding signed document as COSE SIGN", + ); + }, + } + + if error_report.is_problematic() { + return Err(CatalystSignedDocError::new( + error_report, + anyhow::anyhow!("Verification failed for Catalyst Signed Document"), + )); + } + + Ok(()) + } + + /// Returns a signed document `Builder` pre-loaded with the current signed document's + /// data. + #[must_use] + pub fn into_builder(self) -> Builder { + Builder::new() + .with_metadata(self.inner.metadata.clone()) + .with_decoded_content(self.inner.content.decoded_bytes().to_vec()) + .with_signatures(self.inner.signatures.clone()) + } + + /// Convert Catalyst Signed Document into `coset::CoseSign` + 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}")) + } } impl TryFrom<&[u8]> for CatalystSignedDocument { @@ -244,12 +321,15 @@ impl Encode<()> for CatalystSignedDocument { #[cfg(test)] mod tests { + use std::str::FromStr; + + use ed25519_dalek::SigningKey; use metadata::{ContentEncoding, ContentType}; + use rand::rngs::OsRng; use super::*; - #[test] - fn catalyst_signed_doc_cbor_roundtrip_test() { + fn test_metadata() -> anyhow::Result<(UuidV7, UuidV4, Metadata)> { let uuid_v7 = UuidV7::new(); let uuid_v4 = UuidV4::new(); let section = "some section".to_string(); @@ -273,18 +353,22 @@ mod tests { "brand_id": {"id": uuid_v7.to_string()}, "category_id": {"id": uuid_v7.to_string()}, })) - .unwrap(); - let content = vec![1, 2, 4, 5, 6, 7, 8, 9]; + .map_err(|_| anyhow::anyhow!("Invalid example metadata. This should not happen."))?; + Ok((uuid_v7, uuid_v4, metadata)) + } + + #[test] + fn catalyst_signed_doc_cbor_roundtrip_test() { + let (uuid_v7, uuid_v4, metadata) = test_metadata().unwrap(); + let content = serde_json::to_vec(&serde_json::Value::Null).unwrap(); let doc = Builder::new() .with_metadata(metadata.clone()) - .with_content(content.clone()) + .with_decoded_content(content.clone()) .build() .unwrap(); - let mut bytes = Vec::new(); - minicbor::encode_with(doc, &mut bytes, &mut ()).unwrap(); - + let bytes = minicbor::to_vec(doc).unwrap(); let decoded: CatalystSignedDocument = bytes.as_slice().try_into().unwrap(); assert_eq!(decoded.doc_type(), uuid_v4); @@ -293,4 +377,37 @@ mod tests { assert_eq!(decoded.doc_content().decoded_bytes(), &content); assert_eq!(decoded.doc_meta(), metadata.extra()); } + + #[test] + 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(); + let pk = sk.verifying_key(); + + let kid_str = format!( + "kid.catalyst-rbac://cardano/{}/0/0", + base64_url::encode(pk.as_bytes()) + ); + + let kid = KidUri::from_str(&kid_str).unwrap(); + let (_, _, metadata) = test_metadata().unwrap(); + let signed_doc = Builder::new() + .with_decoded_content(content) + .with_metadata(metadata) + .add_signature(sk.to_bytes(), kid.clone()) + .unwrap() + .build() + .unwrap(); + + assert!(signed_doc + .verify(|k| { + if k.to_string() == kid.to_string() { + pk + } else { + k.role0_pk() + } + }) + .is_ok()); + } } diff --git a/rust/signed_doc/src/metadata/content_type.rs b/rust/signed_doc/src/metadata/content_type.rs index 40239692ce..10123ba17f 100644 --- a/rust/signed_doc/src/metadata/content_type.rs +++ b/rust/signed_doc/src/metadata/content_type.rs @@ -18,6 +18,25 @@ pub enum ContentType { Json, } +impl ContentType { + /// Validates the provided `content` bytes to be a defined `ContentType`. + pub fn validate(self, content: &[u8]) -> anyhow::Result<()> { + match self { + Self::Json => { + if let Err(e) = serde_json::from_slice::(content) { + anyhow::bail!("Invalid {self} content: {e}") + } + }, + Self::Cbor => { + if let Err(e) = minicbor::decode::(content) { + anyhow::bail!("Invalid {self} content: {e}") + } + }, + } + Ok(()) + } +} + impl Display for ContentType { fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { match self { @@ -78,3 +97,19 @@ impl TryFrom<&coset::ContentType> for ContentType { Ok(content_type) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_type_validate_test() { + let json_bytes = serde_json::to_vec(&serde_json::Value::Null).unwrap(); + assert!(ContentType::Json.validate(&json_bytes).is_ok()); + assert!(ContentType::Cbor.validate(&json_bytes).is_err()); + + let cbor_bytes = minicbor::to_vec(minicbor::data::Token::Null).unwrap(); + assert!(ContentType::Json.validate(&cbor_bytes).is_err()); + assert!(ContentType::Cbor.validate(&cbor_bytes).is_ok()); + } +} diff --git a/rust/signed_doc/src/metadata/mod.rs b/rust/signed_doc/src/metadata/mod.rs index 5722e99ba8..a770470cec 100644 --- a/rust/signed_doc/src/metadata/mod.rs +++ b/rust/signed_doc/src/metadata/mod.rs @@ -59,6 +59,12 @@ pub struct Metadata { } impl Metadata { + /// Return Document Cryptographic Algorithm + #[must_use] + pub fn algorithm(&self) -> Algorithm { + self.alg + } + /// Return Document Type `UUIDv4`. #[must_use] pub fn doc_type(&self) -> UuidV4 { @@ -156,11 +162,6 @@ impl Metadata { ); }, } - } else { - error_report.missing_field( - "content encoding", - "Missing content encoding field in COSE protected header", - ); } let mut doc_type: Option = None; diff --git a/rust/signed_doc/src/signature/mod.rs b/rust/signed_doc/src/signature/mod.rs index 3c1ff99c7d..6645ca3d23 100644 --- a/rust/signed_doc/src/signature/mod.rs +++ b/rust/signed_doc/src/signature/mod.rs @@ -37,6 +37,11 @@ impl Signatures { self.0.iter().map(|sig| sig.signature.clone()).collect() } + /// Add a new signature + pub fn push(&mut self, kid: KidUri, signature: CoseSignature) { + self.0.push(Signature { kid, signature }); + } + /// Number of signatures. #[must_use] pub fn len(&self) -> usize {