diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 5cd4b84025c..4e80f2a81b4 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -49,6 +49,7 @@ coverallsapp cpus crontabs crontagged +csprng cstring dalek dashmap @@ -122,6 +123,7 @@ jorm jormungandr Jörmungandr jsonschema +kiduri lcov Leay Leshiy @@ -209,6 +211,8 @@ reqwest retriggering ristretto rlib +rngs +rsplit rulelist RULENAME runable @@ -235,6 +239,7 @@ smac stevenj stringzilla subsec +subnetwork symlinkat syscall tacho @@ -262,8 +267,11 @@ unlinkat upnp ureq userid +userinfo utimensat UTXO +uuidv4 +uuidv7 vitss Vkey vkeywitness diff --git a/docs/src/architecture/08_concepts/rbac_kid_uri/.pages b/docs/src/architecture/08_concepts/rbac_kid_uri/.pages new file mode 100644 index 00000000000..0bcc138749f --- /dev/null +++ b/docs/src/architecture/08_concepts/rbac_kid_uri/.pages @@ -0,0 +1,3 @@ +title: RBAC KID (Key Identifier) URI +arrange: + - kiduri.md diff --git a/docs/src/architecture/08_concepts/rbac_kid_uri/kiduri.md b/docs/src/architecture/08_concepts/rbac_kid_uri/kiduri.md new file mode 100644 index 00000000000..fd09c684705 --- /dev/null +++ b/docs/src/architecture/08_concepts/rbac_kid_uri/kiduri.md @@ -0,0 +1,180 @@ +--- +Title: RBAC Key Identifier URI Specification +Category: Catalyst +Status: Proposed +Authors: + - Steven Johnson +Implementors: + - Catalyst Fund 14 +Discussions: [] +Created: 2025-01-05 +License: CC-BY-4.0 +--- + +* [Abstract](#abstract) +* [Motivation: why is this CIP necessary?](#motivation-why-is-this-cip-necessary) +* [Specification](#specification) + * [URI](#uri) + * [`scheme`](#scheme) + * [`authority`](#authority) + * [`authority` - `host`](#authority---host) + * [List of defined hosts](#list-of-defined-hosts) + * [`authority` - `userinfo`](#authority---userinfo) + * [Lists of defined subnetwork `userinfo` values](#lists-of-defined-subnetwork-userinfo-values) + * [Cardano](#cardano) + * [`path`](#path) +* [Reference Implementation](#reference-implementation) +* [Test Vectors](#test-vectors) +* [Rationale: how does this CIP achieve its goals?](#rationale-how-does-this-cip-achieve-its-goals) +* [Path to Active](#path-to-active) + * [Acceptance Criteria](#acceptance-criteria) + * [Implementation Plan](#implementation-plan) +* [Copyright](#copyright) + +## Abstract + +Definition of a [URI] which allows for RBAC keys used for different purposes to be easily and +unambiguously identified. + +## Motivation: why is this CIP necessary? + +There is a need to identify which Key from a RBAC registration was used to sign data. +RBAC defines a universal keychain of different keys that can be used for different purposes. +They can be used not only for Signatures, but also Encryption. + +Therefore, there needs to be an unambiguous and easy to lookup identifier to signify which key was +used for a particular purpose. + +This document defines a [URI] scheme to unambiguously define a particular key with reference to a +particular RBAC keychain. + +## Specification + +### URI + +The RBAC Kid is formatted using a [Universal Resource Identifier]. +Refer to [RFC3986] for the specification of the URI format. + +### `scheme` + +The [scheme](https://datatracker.ietf.org/doc/html/rfc3986#section-3.1) **MUST** be `kid.catalyst-rbac`; + +### `authority` + +The [authority](https://datatracker.ietf.org/doc/html/rfc3986#section-3.2) references the blockchain or network +the key was registered within. + +It is perfectly valid for a Kid to reference a different network than the place where the Key is used. +For example, a `cardano` KID can be used to post documents to `IPFS`. +Its purpose is to define WHERE the key was registered, and nothing more. + +The Authority will consist of a `host` and optional `userinfo`. + +#### `authority` - `host` + +The [host](https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.2) +refers to the network type where the RBAC registration was made. +It **IS NOT** resolvable with **DNS**, and **IS NOT** a public host name. +It is used as a decentralized network identifier. +The consumer of the `KID` must be able to resolve these host names. + +##### List of defined hosts + +| `host` | Description | +| --- | --- | +| `cardano` | Cardano Blockchain | +| `midnight` | Midnight Blockchain | +| `ethereum` | Ethereum Blockchain | +| `cosmos` | Cosmos Blockchain | + +#### `authority` - `userinfo` + +The [userinfo](https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.1) +is used to distinguish a subnetwork from the primary main network. +The absence of `userinfo` is used to indicate the primary main network. + +##### Lists of defined subnetwork `userinfo` values + +###### Cardano + +| `userinfo` | Description | +| --- | --- | +| `preprod` | Cardano Pre-Production Network | +| `preview` | Cardano Preview Network | +| 0x | Cardano network identified by this magic number in hex | + +### `path` + +The [path](https://datatracker.ietf.org/doc/html/rfc3986#section-3.3) defines the actual key within the registration. +Keys are defined relative to the very first Role0 Key registered in any RBAC registration. + +The overall `path` specification is: `//#encrypt` + +* `` - This is the very first role 0 key used to post the registration to the network. + * It is the [Base64 URL] encoded binary data of the role 0 public key. + * This does not change, even if the Initial Role 0 key is revoked. + * This allows for an unambiguous identifier for the RBAC keychain. + * It is not necessarily the key being identified. +* `` - This is the Role number being used. + * It is a positive number, starting at 0, and no greater than 65535. +* `` - This is the rotation of the defined role key being identified. + * It starts at 0 for the first published key for the role, and increments by one for each subsequent published rotation. + * This number refers to the published sequence of keys for the role in the RBAC registration keychain, + not the index used in the key derivation. + * It is positive and no greater than 65535. +* `#encrypt` - [Fragment](https://datatracker.ietf.org/doc/html/rfc3986#section-3.5) + disambiguates Encryption Public Keys from signing public keys. + * Roles can have 1 active public signing key, and 1 active public encryption key. + * By default, the URL is referencing the signing public key. + * If a public encryption key is being identified, then the fragment `#encrypt` is appended to the [Universal Resource Identifier]. + +## Reference Implementation + +The first implementation will be Catalyst Voices. + +## Test Vectors + +* `kid.catalyst-rbac://cardano//0/0` + * A Signing key registered on the Cardano Main network. + * Role 0 - Rotation 0. + In this example, it is exactly the same as the ``. +* `kid.catalyst-rbac://preprod@cardano//7/3` + * A Signing key registered on the Cardano pre-production network. + * Role 7 - Rotation 3. + The Key for Role 7, and its third published rotation + (i.e., the fourth key published, the first is the initial key, plus 3 rotations following it). +* `kid.catalyst-rbac://preprod@cardano//2/0#encrypt` + * A Public Encryption key registered on the Cardano pre-production network. + * Role 2 - Rotation 0. + The initially published Public Encryption Key for Role 2. +* `kid.catalyst-rbac://midnight//0/1` + * A Signing key registered on the Midnight Blockchain Main network + * Role 0 - Rotation 1. + In this example, it is NOT the same as the ``, as it identifies the first rotation after ``. +* `kid.catalyst-rbac://midnight//2/1#encrypt` + * A public encryption key registered on the Midnight Blockchain Main network. + * Role 2 - Rotation 1. + +## Rationale: how does this CIP achieve its goals? + +By creating a [URI] to identify keys, +we allow the unambiguous and flexible identification of any RBAC Key that was used for any purpose. + +## Path to Active + +### Acceptance Criteria + +Working Implementation before Fund 14. + +### Implementation Plan + +Fund 14 project catalyst will deploy this scheme for Key Identification. + +## Copyright + +This document is licensed under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/legalcode). + +[URI]: https://datatracker.ietf.org/doc/html/rfc3986 +[Universal Resource Identifier]: https://datatracker.ietf.org/doc/html/rfc3986 +[RFC3986]: https://datatracker.ietf.org/doc/html/rfc3986 +[Base64 URL]: https://datatracker.ietf.org/doc/html/rfc4648#section-5 diff --git a/rust/Earthfile b/rust/Earthfile index 71a68d8fb3c..230941a0f76 100644 --- a/rust/Earthfile +++ b/rust/Earthfile @@ -58,7 +58,7 @@ build: --args1="--libs=c509-certificate --libs=cardano-blockchain-types --libs=cardano-chain-follower --libs=hermes-ipfs" \ --args2="--libs=cbork-cddl-parser --libs=cbork-abnf-parser --libs=cbork-utils --libs=catalyst-types" \ --args3="--libs=catalyst-voting --libs=immutable-ledger --libs=vote-tx-v1 --libs=vote-tx-v2" \ - --args4="--bins=cbork/cbork --libs=rbac-registration --libs=signed_doc" \ + --args4="--bins=cbork/cbork --libs=rbac-registration --libs=catalyst-signed-doc" \ --args5="--cov_report=$HOME/build/coverage-report.info" \ --output="release/[^\./]+" \ --junit="cat-libs.junit-report.xml" \ diff --git a/rust/signed_doc/Cargo.toml b/rust/signed_doc/Cargo.toml index 74bae38e31a..7502edfaf80 100644 --- a/rust/signed_doc/Cargo.toml +++ b/rust/signed_doc/Cargo.toml @@ -1,6 +1,6 @@ [package] -name = "signed_doc" -version = "0.1.0" +name = "catalyst-signed-doc" +version = "0.0.1" edition.workspace = true authors.workspace = true homepage.workspace = true @@ -11,16 +11,20 @@ license.workspace = true workspace = true [dependencies] - -[dev-dependencies] -clap = { version = "4.5.23", features = ["derive", "env"] } +catalyst-types = { version = "0.0.1", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "r20250114-02" } anyhow = "1.0.95" serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.134" # TODO: Bump this to the latest version and fix the code jsonschema = "0.18.3" coset = "0.3.8" +minicbor = "0.25.1" brotli = "7.0.0" -ed25519-dalek = { version = "2.1.1", features = ["pem"] } -uuid = { version = "1.11.0", features = ["v4", "serde"] } -ulid = { version = "1.1.3", features = ["serde"] } \ No newline at end of file +ed25519-dalek = { version = "2.1.1", features = ["pem", "rand_core"] } +uuid = { version = "1.11.0", features = ["v4", "v7", "serde"] } +hex = "0.4.3" +thiserror = "2.0.9" + +[dev-dependencies] +clap = { version = "4.5.23", features = ["derive", "env"] } +rand = "0.8.5" diff --git a/rust/signed_doc/README.md b/rust/signed_doc/README.md index 78a365ff9e0..53d691be9f6 100644 --- a/rust/signed_doc/README.md +++ b/rust/signed_doc/README.md @@ -2,91 +2,8 @@ # Catalyst signed document -Catalyst signed document is [COSE] based document structure, -particularly `COSE Signed Data Object` [COSE] type. - -## Structure - -This document structure is fully inherits an original [COSE] design and specifies the details -of different [COSE] header's fields. - -### Protected header - -The [COSE] standard defines two types of headers: `protected` and `unprotected`. -Catalyst signed document specifies the following `protected` header fields, -which **must** be present (most of the fields originally defined by this -[spec](https://input-output-hk.github.io/catalyst-voices/architecture/08_concepts/signed_document_metadata/metadata/)): - -* `alg`: `EdDSA` - (this parameter is used to indicate the algorithm used for the security processing, - in this particular case `ed25119` signature algorithm is used). -* `content type`: `application/json` - (this parameter is used to indicate the content type of the payload data, - in this particular case `JSON` format is used). -* `content encoding` (CBOR type `text`): `br` CBOR type `text` - (this parameter is used to indicate the content encodings algorithm of the payload data, - in this particular case [brotli] compression data format is used). -* `type`: CBOR encoded UUID. -* `id`: CBOR encoded ULID. -* `ver`: CBOR encoded ULID. -* `ref`: CBOR encoded ULID or two elements array of ULIDs (optional). -* `template`: CBOR encoded ULID or two elements array of ULIDs (optional). -* `reply`: CBOR encoded ULID or two elements array of ULIDs (optional). -* `section`: CBOR encoded string (optional). -* `collabs`: CBOR encoded array of any CBOR types (optional). - -Precise CDDL definition - -```cddl -; All encoders/decoders of this specification must follow deterministic cbor encoding rules -; https://datatracker.ietf.org/doc/html/draft-ietf-cbor-cde-06 - -protected_header = { - 1 => -8, ; "alg": EdDSA - 3 => 30, ; "content type": Json - "content encoding" => "br", ; payload content encoding, brotli compression - "type" => UUID, - "id" => ULID, - "ver" => ULID, - ? "ref" => reference_type, - ? "template" => reference_type, - ? "reply" => reference_type, - ? "section" => text, - ? "collabs" => [+any], -} - -UUID = #6.37(bytes) -ULID = #6.32780(bytes) -reference_type = ULID / [ULID, ULID] ; either ULID or [ULID, ULID] -``` - -### COSE payload - -The [COSE] signature payload, as mentioned earlier, -the content type of the [COSE] signature payload is JSON, [brotli] compressed. -Which stores an actual document data which should follow to some schema. - -### Signature protected header - -As it mentioned earlier, Catalyst signed document utilizes `COSE Signed Data Object` format, -which allows to provide multi-signature functionality. -In that regard, -each Catalyst signed document [COSE] signature **must** include the following protected header field: - -`protected`: - -* `kid`: CBOR encoded `bytes` type. - -Precise CDDL definition - -```cddl -; All encoders/decoders of this specification must follow deterministic cbor encoding rules -; https://datatracker.ietf.org/doc/html/draft-ietf-cbor-cde-06 - -signature_protected_header = { - 4 => bytes ; "kid" -} -``` +Catalyst signed document crate implementation based on this +[spec](https://input-output-hk.github.io/catalyst-libs/architecture/08_concepts/signed_doc/spec/) ## Example @@ -100,34 +17,12 @@ Prepare non-signed document, `meta.json` file should follow the [`meta.schema.json`](./meta.schema.json). ```shell -cargo run -p signed_doc --example mk_signed_doc build +cargo run -p catalyst-signed-doc --example mk_signed_doc build signed_doc/doc.json signed_doc/schema.json signed_doc/doc.cose signed_doc/meta.json ``` -Sign document - -```shell -cargo run -p signed_doc --example mk_signed_doc sign private.pem signed_doc/doc.cose kid_1 -``` - -Verify document +Inspect document ```shell -cargo run -p signed_doc --example mk_signed_doc verify -public.pem signed_doc/doc.cose signed_doc/schema.json +cargo run -p catalyst-signed-doc --example mk_signed_doc inspect signed_doc/doc.cose ``` - -Catalyst signed document CBOR bytes example - -```cbor -845861A6012703183270636F6E74656E7420656E636F64696E676262726474797065D825500CE8AB3892584FBCA62E7F -AA6E58318F626964D9800C500193929C1D227F1977FED19443841F0B63766572D9800C500193929C1D227F1977FED194 -43841F0BA0584E1B6D00209C05762C9B4E1EAC3DCA9286B50888CBDE8E99A2EB532C3A0D83D6F6462707ECDFF7F9B74B -8904098479CA4221337F7DB97FDA25AFCC10ECB75722C91A485AAC1158BA6F90619221066C828347A104446B696431A0 -584090DF51433D97728ACF3851C5D3CA2908F76589EA925AF434C5619234E4B1BA7B12A124EA79503562B33214EBC730 -C9837E1CA909BB8163D7904B09C3FD6A5B0B8347A104446B696432A05840AB318FEF3FF46E69E760540B0B44E9E8A51A -84F23EC8A870ECDEBF9AD98EBB8212EBE5EA5FDBA87C98DF8DF259BE7873FE8B9EB54CC6558337B5C95D90CC3504 -``` - -[COSE]: https://datatracker.ietf.org/doc/html/rfc9052 -[brotli]: https://datatracker.ietf.org/doc/html/rfc7932 diff --git a/rust/signed_doc/examples/mk_signed_doc.rs b/rust/signed_doc/examples/mk_signed_doc.rs index 0eb8c15c06f..2bc3186c468 100644 --- a/rust/signed_doc/examples/mk_signed_doc.rs +++ b/rust/signed_doc/examples/mk_signed_doc.rs @@ -8,12 +8,10 @@ use std::{ path::PathBuf, }; +use catalyst_signed_doc::{CatalystSignedDocument, Decode, Decoder, KidUri, Metadata}; use clap::Parser; -use coset::CborSerializable; -use ed25519_dalek::{ - ed25519::signature::Signer, - pkcs8::{DecodePrivateKey, DecodePublicKey}, -}; +use coset::{iana::CoapContentFormat, CborSerializable}; +use ed25519_dalek::{ed25519::signature::Signer, pkcs8::DecodePrivateKey}; fn main() { if let Err(err) = Cli::parse().exec() { @@ -43,63 +41,22 @@ enum Cli { /// This exact file would be modified and new signature would be added doc: PathBuf, /// Signer kid - kid: String, + kid: KidUri, }, - /// Verifies COSE document - Verify { - /// Path to the public key in PEM format - pk: PathBuf, + /// Inspects Catalyst Signed Document + Inspect { /// Path to the fully formed (should has at least one signature) COSE document - doc: PathBuf, - /// Path to the json schema (Draft 7) to validate document against it - schema: PathBuf, + path: PathBuf, + }, + /// Inspects Catalyst Signed Document from hex-encoded bytes + InspectBytes { + /// Hex-formatted COSE SIGN Bytes + cose_sign_hex: String, }, } -const CONTENT_ENCODING_KEY: &str = "content encoding"; -const CONTENT_ENCODING_VALUE: &str = "br"; +const CONTENT_ENCODING_KEY: &str = "Content-Encoding"; const UUID_CBOR_TAG: u64 = 37; -const ULID_CBOR_TAG: u64 = 32780; - -#[derive(Debug, serde::Deserialize)] -struct Metadata { - r#type: uuid::Uuid, - id: ulid::Ulid, - ver: ulid::Ulid, - r#ref: Option, - template: Option, - reply: Option, - section: Option, -} - -#[derive(Debug, serde::Deserialize)] -#[serde(untagged)] -enum DocumentRef { - /// Reference to the latest document - Latest { id: ulid::Ulid }, - /// Reference to the specific document version - WithVer { id: ulid::Ulid, ver: ulid::Ulid }, -} - -fn encode_cbor_ulid(ulid: &ulid::Ulid) -> coset::cbor::Value { - coset::cbor::Value::Tag( - ULID_CBOR_TAG, - coset::cbor::Value::Bytes(ulid.to_bytes().to_vec()).into(), - ) -} - -fn decode_cbor_ulid(val: &coset::cbor::Value) -> anyhow::Result { - let Some((ULID_CBOR_TAG, coset::cbor::Value::Bytes(bytes))) = val.as_tag() else { - anyhow::bail!("Invalid CBOR encoded ULID type"); - }; - let ulid = ulid::Ulid::from_bytes( - bytes - .clone() - .try_into() - .map_err(|_| anyhow::anyhow!("Invalid CBOR encoded ULID type, invalid bytes size"))?, - ); - Ok(ulid) -} fn encode_cbor_uuid(uuid: &uuid::Uuid) -> coset::cbor::Value { coset::cbor::Value::Tag( @@ -108,43 +65,6 @@ fn encode_cbor_uuid(uuid: &uuid::Uuid) -> coset::cbor::Value { ) } -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) -} - -fn encode_cbor_document_ref(doc_ref: &DocumentRef) -> coset::cbor::Value { - match doc_ref { - DocumentRef::Latest { id } => encode_cbor_ulid(id), - DocumentRef::WithVer { id, ver } => { - coset::cbor::Value::Array(vec![encode_cbor_ulid(id), encode_cbor_ulid(ver)]) - }, - } -} - -#[allow(clippy::indexing_slicing)] -fn decode_cbor_document_ref(val: &coset::cbor::Value) -> anyhow::Result { - if let Ok(id) = decode_cbor_ulid(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_ulid(&array[0])?; - let ver = decode_cbor_ulid(&array[1])?; - Ok(DocumentRef::WithVer { id, ver }) - } -} - impl Cli { fn exec(self) -> anyhow::Result<()> { match self { @@ -156,23 +76,31 @@ impl Cli { } => { let doc_schema = load_schema_from_file(&schema)?; let json_doc = load_json_from_file(&doc)?; - let json_meta = load_json_from_file(&meta)?; + let json_meta = load_json_from_file(&meta) + .map_err(|e| anyhow::anyhow!("Failed to load metadata from file: {e}"))?; + println!("{json_meta}"); validate_json(&json_doc, &doc_schema)?; let compressed_doc = brotli_compress_json(&json_doc)?; let empty_cose_sign = build_empty_cose_doc(compressed_doc, &json_meta); store_cose_file(empty_cose_sign, &output)?; }, Self::Sign { sk, doc, kid } => { - let sk = load_secret_key_from_file(&sk)?; - let mut cose = load_cose_from_file(&doc)?; - add_signature_to_cose(&mut cose, &sk, 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)?; }, - Self::Verify { pk, doc, schema } => { - let pk = load_public_key_from_file(&pk)?; - let schema = load_schema_from_file(&schema)?; - let cose = load_cose_from_file(&doc)?; - validate_cose(&cose, &pk, &schema)?; + 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); + }, + Self::InspectBytes { cose_sign_hex } => { + let cose_bytes = hex::decode(&cose_sign_hex)?; + decode_signed_doc(&cose_bytes); }, } println!("Done"); @@ -180,6 +108,21 @@ impl Cli { } } +fn decode_signed_doc(cose_bytes: &[u8]) { + println!( + "Decoding {} bytes: {}", + cose_bytes.len(), + hex::encode(cose_bytes) + ); + match CatalystSignedDocument::decode(&mut Decoder::new(cose_bytes), &mut ()) { + Ok(cat_signed_doc) => { + println!("This is a valid Catalyst Signed Document."); + println!("{cat_signed_doc}"); + }, + Err(e) => eprintln!("Invalid Catalyst Signed Document, err: {e}"), + } +} + fn load_schema_from_file(schema_path: &PathBuf) -> anyhow::Result { let schema_file = File::open(schema_path)?; let schema_json = serde_json::from_reader(schema_file)?; @@ -216,64 +159,35 @@ fn brotli_compress_json(doc: &serde_json::Value) -> anyhow::Result> { Ok(buf) } -fn brotli_decompress_json(mut doc_bytes: &[u8]) -> anyhow::Result { - let mut buf = Vec::new(); - brotli::BrotliDecompress(&mut doc_bytes, &mut buf)?; - let json_doc = serde_json::from_slice(&buf)?; - Ok(json_doc) -} +fn build_empty_cose_doc(doc_bytes: Vec, meta: &Metadata) -> coset::CoseSign { + let mut builder = + coset::HeaderBuilder::new().content_format(CoapContentFormat::from(meta.content_type())); -fn cose_protected_header() -> coset::Header { - coset::HeaderBuilder::new() - .algorithm(coset::iana::Algorithm::EdDSA) - .content_format(coset::iana::CoapContentFormat::Json) - .text_value( + if let Some(content_encoding) = meta.content_encoding() { + builder = builder.text_value( CONTENT_ENCODING_KEY.to_string(), - CONTENT_ENCODING_VALUE.to_string().into(), - ) - .build() -} - -fn build_empty_cose_doc(doc_bytes: Vec, meta: &Metadata) -> coset::CoseSign { - let mut protected_header = cose_protected_header(); + format!("{content_encoding}").into(), + ); + } + let mut protected_header = builder.build(); protected_header.rest.push(( coset::Label::Text("type".to_string()), - encode_cbor_uuid(&meta.r#type), + encode_cbor_uuid(&meta.doc_type()), )); protected_header.rest.push(( coset::Label::Text("id".to_string()), - encode_cbor_ulid(&meta.id), + encode_cbor_uuid(&meta.doc_id()), )); protected_header.rest.push(( coset::Label::Text("ver".to_string()), - encode_cbor_ulid(&meta.ver), + encode_cbor_uuid(&meta.doc_ver()), )); - if let Some(r#ref) = &meta.r#ref { - protected_header.rest.push(( - coset::Label::Text("ref".to_string()), - encode_cbor_document_ref(r#ref), - )); - } - if let Some(template) = &meta.template { - protected_header.rest.push(( - coset::Label::Text("template".to_string()), - encode_cbor_document_ref(template), - )); - } - if let Some(reply) = &meta.reply { - protected_header.rest.push(( - coset::Label::Text("reply".to_string()), - encode_cbor_document_ref(reply), - )); - } - if let Some(section) = &meta.section { - protected_header.rest.push(( - coset::Label::Text("section".to_string()), - coset::cbor::Value::Text(section.clone()), - )); - } + let meta_rest = meta.extra().header_rest().unwrap_or_default(); + if !meta_rest.is_empty() { + protected_header.rest.extend(meta_rest); + } coset::CoseSignBuilder::new() .protected(protected_header) .payload(doc_bytes) @@ -290,7 +204,9 @@ fn load_cose_from_file(cose_path: &PathBuf) -> anyhow::Result { fn store_cose_file(cose: coset::CoseSign, output: &PathBuf) -> anyhow::Result<()> { let mut cose_file = File::create(output)?; - let cose_bytes = cose.to_vec().map_err(|e| anyhow::anyhow!("{e}"))?; + let cose_bytes = cose + .to_vec() + .map_err(|e| anyhow::anyhow!("Failed to Store COSE SIGN: {e}"))?; cose_file.write_all(&cose_bytes)?; Ok(()) } @@ -301,14 +217,10 @@ 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) -} - 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()); + 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(); @@ -316,140 +228,3 @@ fn add_signature_to_cose(cose: &mut coset::CoseSign, sk: &ed25519_dalek::Signing signature.signature = sk.sign(&data_to_sign).to_vec(); cose.signatures.push(signature); } - -fn validate_cose( - cose: &coset::CoseSign, pk: &ed25519_dalek::VerifyingKey, schema: &jsonschema::JSONSchema, -) -> anyhow::Result<()> { - validate_cose_protected_header(cose)?; - - let Some(payload) = &cose.payload else { - anyhow::bail!("COSE missing payload field with the JSON content in it"); - }; - let json_doc = brotli_decompress_json(payload.as_slice())?; - validate_json(&json_doc, schema)?; - - for sign in &cose.signatures { - anyhow::ensure!( - !sign.protected.header.key_id.is_empty(), - "COSE missing signature protected header `kid` field " - ); - - let data_to_sign = cose.tbs_data(&[], sign); - let signature_bytes = sign.signature.as_slice().try_into().map_err(|_| { - anyhow::anyhow!( - "Invalid signature bytes size: expected {}, provided {}.", - ed25519_dalek::Signature::BYTE_SIZE, - sign.signature.len() - ) - })?; - let signature = ed25519_dalek::Signature::from_bytes(signature_bytes); - pk.verify_strict(&data_to_sign, &signature)?; - } - - Ok(()) -} - -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 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"); - }; - 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_ulid(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_ulid(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(()) -} diff --git a/rust/signed_doc/meta.schema.json b/rust/signed_doc/meta.schema.json index 88cfec84afe..c3fa82d4f00 100644 --- a/rust/signed_doc/meta.schema.json +++ b/rust/signed_doc/meta.schema.json @@ -2,29 +2,33 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Catalyst signed document metadata json schema", - "type": "object", - "additionalProperties": false, - "properties": { - "type": { + "definitions": { + "uuidv4": { "type": "string", "format": "uuid", "examples": [ "0ce8ab38-9258-4fbc-a62e-7faa6e58318f" ] }, - "id": { + "uuidv7": { "type": "string", - "format": "ulid", + "format": "uuid", "examples": [ - "01JE99R792FWCQFZPHJH1R87RB" + "0193ae7c-8131-7fe6-91f0-b451ea229b11" ] + } + }, + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "$ref": "#/definitions/uuidv4" + }, + "id": { + "$ref": "#/definitions/uuidv7" }, "ver": { - "type": "string", - "format": "ulid", - "examples": [ - "01JE99R792FWCQFZPHJH1R87RB" - ] + "$ref": "#/definitions/uuidv7" }, "ref": { "anyOf": [ @@ -32,23 +36,16 @@ "type": "object", "properties": { "id": { - "type": "string", - "format": "ulid" + "$ref": "#/definitions/uuidv7" } } }, { - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "ulid" - }, - "ver": { - "type": "string", - "format": "ulid" - } - } + "type": "array", + "items": { + "$ref": "#/definitions/uuidv7" + }, + "minItems": 2 } ] }, @@ -58,8 +55,7 @@ "type": "object", "properties": { "id": { - "type": "string", - "format": "ulid" + "$ref": "#/definitions/uuidv7" } } }, @@ -67,12 +63,10 @@ "type": "object", "properties": { "id": { - "type": "string", - "format": "ulid" + "$ref": "#/definitions/uuidv7" }, "ver": { - "type": "string", - "format": "ulid" + "$ref": "#/definitions/uuidv7" } } } @@ -84,8 +78,7 @@ "type": "object", "properties": { "id": { - "type": "string", - "format": "ulid" + "$ref": "#/definitions/uuidv7" } } }, @@ -93,12 +86,10 @@ "type": "object", "properties": { "id": { - "type": "string", - "format": "ulid" + "$ref": "#/definitions/uuidv7" }, "ver": { - "type": "string", - "format": "ulid" + "$ref": "#/definitions/uuidv7" } } } @@ -106,11 +97,25 @@ }, "section": { "type": "string" + }, + "content-type": { + "type": "string", + "examples": [ + "json", + "cbor" + ] + }, + "content-encoding": { + "type": "string", + "examples": [ + "br" + ] } }, "required": [ "type", "id", - "ver" + "ver", + "content-type" ] -} \ No newline at end of file +} diff --git a/rust/signed_doc/src/content.rs b/rust/signed_doc/src/content.rs new file mode 100644 index 00000000000..3cc76d23af1 --- /dev/null +++ b/rust/signed_doc/src/content.rs @@ -0,0 +1,41 @@ +//! Catalyst Signed Document Content Payload + +use crate::metadata::{ContentEncoding, ContentType}; + +/// Decompressed Document Content type bytes. +#[derive(Debug, Clone, PartialEq)] +pub struct Content(Vec, ContentType); + +impl Content { + /// Creates a new `Content` value, + /// verifies a Document's content, that it is correctly encoded and it corresponds and + /// parsed to the specified type + pub fn new( + mut content: Vec, content_type: ContentType, encoding: Option, + ) -> anyhow::Result { + if let Some(content_encoding) = encoding { + content = content_encoding + .decode(content.as_slice()) + .map_err(|e| anyhow::anyhow!("Failed to decode {encoding:?} content: {e}"))?; + } + + Ok(Self(content, content_type)) + } + + /// Return `true` if Document's content type is Json + #[must_use] + pub fn is_json(&self) -> bool { + matches!(self.1, ContentType::Json) + } + + /// Return `true` if Document's content type is Json + #[must_use] + pub fn is_cbor(&self) -> bool { + matches!(self.1, ContentType::Cbor) + } + + /// Return content bytes + pub fn bytes(&self) -> &[u8] { + self.0.as_slice() + } +} diff --git a/rust/signed_doc/src/error.rs b/rust/signed_doc/src/error.rs new file mode 100644 index 00000000000..a58ff104e34 --- /dev/null +++ b/rust/signed_doc/src/error.rs @@ -0,0 +1,29 @@ +//! Catalyst Signed Document errors. + +/// Catalyst Signed Document error. +#[derive(thiserror::Error, Debug)] +#[error("Catalyst Signed Document Error: {0:?}")] +pub struct Error(pub(crate) List); + +/// List of errors. +#[derive(Debug)] +pub(crate) struct List(pub(crate) Vec); + +impl From> for List { + fn from(e: Vec) -> Self { + Self(e) + } +} + +impl From> for Error { + fn from(e: Vec) -> Self { + Self(e.into()) + } +} + +impl Error { + /// List of errors. + pub fn errors(&self) -> &Vec { + &self.0 .0 + } +} diff --git a/rust/signed_doc/src/lib.rs b/rust/signed_doc/src/lib.rs index 8ed8e786869..f93201aa648 100644 --- a/rust/signed_doc/src/lib.rs +++ b/rust/signed_doc/src/lib.rs @@ -1 +1,151 @@ //! Catalyst documents signing crate + +mod content; +mod error; +mod metadata; +mod signature; + +use std::{ + convert::TryFrom, + fmt::{Display, Formatter}, + sync::Arc, +}; + +use anyhow::anyhow; +use content::Content; +use coset::CborSerializable; +pub use metadata::{AdditionalFields, DocumentRef, Metadata, UuidV7}; +pub use minicbor::{decode, Decode, Decoder}; +pub use signature::KidUri; +use signature::Signatures; + +/// Inner type that holds the Catalyst Signed Document with parsing errors. +struct InnerCatalystSignedDocument { + /// Document Metadata + metadata: Metadata, + /// Document Content + content: Content, + /// Signatures + signatures: Signatures, +} + +/// 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. +pub struct CatalystSignedDocument { + /// Catalyst Signed Document metadata, raw doc, with content errors. + inner: Arc, +} + +impl Display for CatalystSignedDocument { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + writeln!(f, "{}", self.inner.metadata)?; + writeln!(f, "Signature Information [")?; + for kid in &self.inner.signatures.kids() { + writeln!(f, " {kid}")?; + } + writeln!(f, "]\n") + } +} + +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) -> uuid::Uuid { + self.inner.metadata.doc_type() + } + + /// Return Document ID `UUIDv7`. + #[must_use] + pub fn doc_id(&self) -> uuid::Uuid { + self.inner.metadata.doc_id() + } + + /// Return Document Version `UUIDv7`. + #[must_use] + pub fn doc_ver(&self) -> uuid::Uuid { + self.inner.metadata.doc_ver() + } + + /// Return document `Content`. + #[must_use] + pub fn doc_content(&self) -> &Content { + &self.inner.content + } + + /// Return document metadata content. + #[must_use] + pub fn doc_meta(&self) -> &AdditionalFields { + self.inner.metadata.extra() + } + + /// Return a Document's signatures + #[must_use] + pub fn signatures(&self) -> &Signatures { + &self.inner.signatures + } +} + +impl Decode<'_, ()> for CatalystSignedDocument { + fn decode(d: &mut Decoder<'_>, (): &mut ()) -> Result { + 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_slice(cose_bytes).map_err(|e| { + minicbor::decode::Error::message(format!("Invalid COSE Sign document: {e}")) + })?; + + let mut errors = Vec::new(); + + let metadata = Metadata::try_from(&cose_sign.protected).map_or_else( + |e| { + errors.extend(e.0 .0); + None + }, + Some, + ); + let signatures = Signatures::try_from(&cose_sign.signatures).map_or_else( + |e| { + errors.extend(e.0 .0); + None + }, + Some, + ); + + if cose_sign.payload.is_none() { + errors.push(anyhow!("Document Content is missing")); + } + + match (cose_sign.payload, metadata, signatures) { + (Some(payload), Some(metadata), Some(signatures)) => { + let content = Content::new( + payload, + metadata.content_type(), + metadata.content_encoding(), + ) + .map_err(|e| { + errors.push(anyhow!("Invalid Document Content: {e}")); + minicbor::decode::Error::message(error::Error::from(errors)) + })?; + + Ok(CatalystSignedDocument { + inner: InnerCatalystSignedDocument { + metadata, + content, + signatures, + } + .into(), + }) + }, + _ => Err(minicbor::decode::Error::message(error::Error::from(errors))), + } + } +} diff --git a/rust/signed_doc/src/metadata/additional_fields.rs b/rust/signed_doc/src/metadata/additional_fields.rs new file mode 100644 index 00000000000..384e74bf48f --- /dev/null +++ b/rust/signed_doc/src/metadata/additional_fields.rs @@ -0,0 +1,275 @@ +//! Catalyst Signed Document Additional Fields. + +use anyhow::anyhow; +use coset::{cbor::Value, Label, ProtectedHeader}; + +use super::{cose_protected_header_find, decode_cbor_uuid, encode_cbor_value, DocumentRef, UuidV4}; + +/// Additional Metadata Fields. +/// +/// These values are extracted from the COSE Sign protected header labels. +#[derive(Default, Debug, serde::Serialize, serde::Deserialize)] +pub struct AdditionalFields { + /// Reference to the latest document. + #[serde(rename = "ref")] + pub(super) doc_ref: Option, + /// Reference to the document template. + pub(super) template: Option, + /// Reference to the document reply. + pub(super) reply: Option, + /// Reference to the document section. + pub(super) section: Option, + /// Reference to the document collaborators. Collaborator type is TBD. + pub(super) collabs: Option>, + /// Unique identifier for the brand that is running the voting. + pub(super) brand_id: Option, + /// Unique identifier for the campaign of voting. + pub(super) campaign_id: Option, + /// Unique identifier for the election. + pub(super) election_id: Option, + /// Unique identifier for the voting category as a collection of proposals. + pub(super) category_id: Option, +} + +impl AdditionalFields { + /// Returns the COSE Sign protected header REST fields. + /// + /// # Errors + /// If any internal field cannot be converted into `Value`. + pub fn header_rest(&self) -> anyhow::Result> { + self.try_into() + } +} + +impl TryFrom<&AdditionalFields> for Vec<(Label, Value)> { + type Error = anyhow::Error; + + fn try_from(fields: &AdditionalFields) -> anyhow::Result { + let mut vec = Vec::new(); + + if let Some(doc_ref) = &fields.doc_ref { + vec.push((Label::Text("ref".to_string()), doc_ref.try_into()?)); + } + + if let Some(template) = &fields.template { + vec.push((Label::Text("template".to_string()), template.try_into()?)); + } + + if let Some(reply) = &fields.reply { + vec.push((Label::Text("reply".to_string()), reply.try_into()?)); + } + + if let Some(section) = &fields.section { + vec.push(( + Label::Text("section".to_string()), + Value::Text(section.clone()), + )); + } + + if let Some(collabs) = &fields.collabs { + if !collabs.is_empty() { + vec.push(( + Label::Text("collabs".to_string()), + Value::Array(collabs.iter().cloned().map(Value::Text).collect()), + )); + } + } + + if let Some(brand_id) = &fields.brand_id { + vec.push(( + Label::Text("brand_id".to_string()), + encode_cbor_value(brand_id)?, + )); + } + + if let Some(campaign_id) = &fields.campaign_id { + vec.push(( + Label::Text("campaign_id".to_string()), + encode_cbor_value(campaign_id)?, + )); + } + + if let Some(election_id) = &fields.election_id { + vec.push(( + Label::Text("election_id".to_string()), + encode_cbor_value(election_id)?, + )); + } + + if let Some(category_id) = &fields.category_id { + vec.push(( + Label::Text("category_id".to_string()), + encode_cbor_value(*category_id)?, + )); + } + + Ok(vec) + } +} + +impl TryFrom<&ProtectedHeader> for AdditionalFields { + type Error = crate::error::Error; + + #[allow(clippy::too_many_lines)] + fn try_from(protected: &ProtectedHeader) -> Result { + let mut extra = AdditionalFields::default(); + let mut errors = Vec::new(); + + if let Some(cbor_doc_ref) = + cose_protected_header_find(protected, |key| key == &Label::Text("ref".to_string())) + { + match DocumentRef::try_from(cbor_doc_ref) { + Ok(doc_ref) => { + extra.doc_ref = Some(doc_ref); + }, + Err(e) => { + errors.push(anyhow!( + "Invalid COSE protected header `ref` field, err: {e}" + )); + }, + } + } + + if let Some(cbor_doc_template) = + cose_protected_header_find(protected, |key| key == &Label::Text("template".to_string())) + { + match DocumentRef::try_from(cbor_doc_template) { + Ok(doc_template) => { + extra.template = Some(doc_template); + }, + Err(e) => { + errors.push(anyhow!( + "Invalid COSE protected header `template` field, err: {e}" + )); + }, + } + } + + if let Some(cbor_doc_reply) = + cose_protected_header_find(protected, |key| key == &Label::Text("reply".to_string())) + { + match DocumentRef::try_from(cbor_doc_reply) { + Ok(doc_reply) => { + extra.reply = Some(doc_reply); + }, + Err(e) => { + errors.push(anyhow!( + "Invalid COSE protected header `reply` field, err: {e}" + )); + }, + } + } + + if let Some(cbor_doc_section) = + cose_protected_header_find(protected, |key| key == &Label::Text("section".to_string())) + { + match cbor_doc_section.clone().into_text() { + Ok(doc_section) => { + extra.section = Some(doc_section); + }, + Err(e) => { + errors.push(anyhow!( + "Invalid COSE protected header `section` field, err: {e:?}" + )); + }, + } + } + + if let Some(cbor_doc_collabs) = + cose_protected_header_find(protected, |key| key == &Label::Text("collabs".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.into_text() { + Ok(collaborator) => { + c.push(collaborator); + }, + Err(e) => { + errors.push(anyhow!( + "Invalid Collaborator at index {ids} of COSE protected header `collabs` field, err: {e:?}" + )); + }, + } + } + + if !c.is_empty() { + extra.collabs = Some(c); + } + }, + Err(e) => { + errors.push(anyhow!( + "Invalid COSE protected header `collabs` field, err: {e:?}" + )); + }, + } + } + + if let Some(cbor_doc_brand_id) = + cose_protected_header_find(protected, |key| key == &Label::Text("brand_id".to_string())) + { + match decode_cbor_uuid(cbor_doc_brand_id.clone()) { + Ok(brand_id) => { + extra.brand_id = Some(brand_id); + }, + Err(e) => { + errors.push(anyhow!( + "Invalid COSE protected header `brand_id` field, err: {e}" + )); + }, + } + } + + if let Some(cbor_doc_campaign_id) = cose_protected_header_find(protected, |key| { + key == &Label::Text("campaign_id".to_string()) + }) { + match decode_cbor_uuid(cbor_doc_campaign_id.clone()) { + Ok(campaign_id) => { + extra.campaign_id = Some(campaign_id); + }, + Err(e) => { + errors.push(anyhow!( + "Invalid COSE protected header `campaign_id` field, err: {e}" + )); + }, + } + } + + if let Some(cbor_doc_election_id) = cose_protected_header_find(protected, |key| { + key == &Label::Text("election_id".to_string()) + }) { + match decode_cbor_uuid(cbor_doc_election_id.clone()) { + Ok(election_id) => { + extra.election_id = Some(election_id); + }, + Err(e) => { + errors.push(anyhow!( + "Invalid COSE protected header `election_id` field, err: {e}" + )); + }, + } + } + + if let Some(cbor_doc_category_id) = cose_protected_header_find(protected, |key| { + key == &Label::Text("category_id".to_string()) + }) { + match decode_cbor_uuid(cbor_doc_category_id.clone()) { + Ok(category_id) => { + extra.category_id = Some(category_id); + }, + Err(e) => { + errors.push(anyhow!( + "Invalid COSE protected header `category_id` field, err: {e}" + )); + }, + } + } + + if errors.is_empty() { + Ok(extra) + } else { + Err(errors.into()) + } + } +} diff --git a/rust/signed_doc/src/metadata/content_encoding.rs b/rust/signed_doc/src/metadata/content_encoding.rs new file mode 100644 index 00000000000..3134b9ef7bd --- /dev/null +++ b/rust/signed_doc/src/metadata/content_encoding.rs @@ -0,0 +1,68 @@ +//! Document Payload Content Encoding. + +use std::{ + fmt::{Display, Formatter}, + str::FromStr, +}; + +use serde::{de, Deserialize, Deserializer}; + +/// IANA `CoAP` Content Encoding. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum ContentEncoding { + /// Brotli compression.format. + Brotli, +} + +impl Display for ContentEncoding { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + Self::Brotli => write!(f, "br"), + } + } +} + +impl FromStr for ContentEncoding { + type Err = anyhow::Error; + + fn from_str(encoding: &str) -> Result { + match encoding { + "br" => Ok(ContentEncoding::Brotli), + _ => anyhow::bail!("Unsupported Content Encoding: {encoding:?}"), + } + } +} + +impl<'de> Deserialize<'de> for ContentEncoding { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de> { + let s = String::deserialize(deserializer)?; + FromStr::from_str(&s).map_err(de::Error::custom) + } +} + +impl TryFrom<&coset::cbor::Value> for ContentEncoding { + type Error = anyhow::Error; + + fn try_from(val: &coset::cbor::Value) -> anyhow::Result { + match val.as_text() { + Some(encoding) => encoding.parse(), + None => { + anyhow::bail!("Expected Content Encoding to be a string"); + }, + } + } +} + +impl ContentEncoding { + /// Decompress a Brotli payload + pub fn decode(self, mut payload: &[u8]) -> anyhow::Result> { + match self { + Self::Brotli => { + let mut buf = Vec::new(); + brotli::BrotliDecompress(&mut payload, &mut buf)?; + Ok(buf) + }, + } + } +} diff --git a/rust/signed_doc/src/metadata/content_type.rs b/rust/signed_doc/src/metadata/content_type.rs new file mode 100644 index 00000000000..b45105a04e6 --- /dev/null +++ b/rust/signed_doc/src/metadata/content_type.rs @@ -0,0 +1,69 @@ +//! Document Payload Content Type. + +use std::{ + fmt::{Display, Formatter}, + str::FromStr, +}; + +use coset::iana::CoapContentFormat; +use serde::{de, Deserialize, Deserializer}; + +/// Payload Content Type. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ContentType { + /// 'application/cbor' + Cbor, + /// 'application/json' + Json, +} + +impl Display for ContentType { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + match self { + Self::Cbor => write!(f, "cbor"), + Self::Json => write!(f, "json"), + } + } +} + +impl FromStr for ContentType { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "cbor" => Ok(Self::Cbor), + "json" => Ok(Self::Json), + _ => anyhow::bail!("Unsupported Content Type: {s:?}"), + } + } +} + +impl<'de> Deserialize<'de> for ContentType { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de> { + let s = String::deserialize(deserializer)?; + FromStr::from_str(&s).map_err(de::Error::custom) + } +} + +impl From for CoapContentFormat { + fn from(value: ContentType) -> Self { + match value { + ContentType::Cbor => Self::Cbor, + ContentType::Json => Self::Json, + } + } +} + +impl TryFrom<&coset::ContentType> for ContentType { + type Error = anyhow::Error; + + fn try_from(value: &coset::ContentType) -> Result { + let content_type = match value { + coset::ContentType::Assigned(CoapContentFormat::Json) => ContentType::Json, + coset::ContentType::Assigned(CoapContentFormat::Cbor) => ContentType::Cbor, + _ => anyhow::bail!("Unsupported Content Type {value:?}"), + }; + Ok(content_type) + } +} diff --git a/rust/signed_doc/src/metadata/document_id.rs b/rust/signed_doc/src/metadata/document_id.rs new file mode 100644 index 00000000000..ca71ff260b9 --- /dev/null +++ b/rust/signed_doc/src/metadata/document_id.rs @@ -0,0 +1,29 @@ +//! Document ID. +use std::fmt::{Display, Formatter}; + +use super::UuidV7; + +/// Catalyst Document ID. +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, serde::Deserialize)] +#[serde(from = "UuidV7")] +pub struct DocumentId(UuidV7); + +impl DocumentId { + /// Returns the `uuid::Uuid` type. + #[must_use] + pub fn uuid(&self) -> uuid::Uuid { + self.0.uuid() + } +} + +impl Display for DocumentId { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + write!(f, "{}", self.0) + } +} + +impl From for DocumentId { + fn from(uuid: UuidV7) -> Self { + Self(uuid) + } +} diff --git a/rust/signed_doc/src/metadata/document_ref.rs b/rust/signed_doc/src/metadata/document_ref.rs new file mode 100644 index 00000000000..fc4de6ea10e --- /dev/null +++ b/rust/signed_doc/src/metadata/document_ref.rs @@ -0,0 +1,64 @@ +//! Catalyst Signed Document Metadata. +use coset::cbor::Value; + +use super::{decode_cbor_uuid, encode_cbor_value, UuidV7}; + +/// Reference to a Document. +#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[serde(untagged)] +pub enum DocumentRef { + /// Reference to the latest document + Latest { + /// Document ID UUID + id: UuidV7, + }, + /// Reference to the specific document version + WithVer { + /// Document ID UUID, + id: UuidV7, + /// Document Ver UUID + ver: UuidV7, + }, +} + +impl TryFrom<&DocumentRef> for Value { + type Error = anyhow::Error; + + fn try_from(value: &DocumentRef) -> Result { + match value { + DocumentRef::Latest { id } => encode_cbor_value(id), + DocumentRef::WithVer { id, ver } => { + Ok(Value::Array(vec![ + encode_cbor_value(id)?, + encode_cbor_value(ver)?, + ])) + }, + } + } +} + +impl TryFrom<&Value> for DocumentRef { + type Error = anyhow::Error; + + #[allow(clippy::indexing_slicing)] + fn try_from(val: &Value) -> anyhow::Result { + if let Ok(id) = decode_cbor_uuid(val.clone()) { + Ok(DocumentRef::Latest { id }) + } else { + let Some(array) = val.as_array() else { + anyhow::bail!("Document Reference must be either a single UUID or an array of two"); + }; + anyhow::ensure!( + array.len() == 2, + "Document Reference array of two UUIDs was expected" + ); + let id = decode_cbor_uuid(array[0].clone())?; + let ver = decode_cbor_uuid(array[1].clone())?; + anyhow::ensure!( + ver >= id, + "Document Reference Version can never be smaller than its ID" + ); + Ok(DocumentRef::WithVer { id, ver }) + } + } +} diff --git a/rust/signed_doc/src/metadata/document_type.rs b/rust/signed_doc/src/metadata/document_type.rs new file mode 100644 index 00000000000..f1383d222e5 --- /dev/null +++ b/rust/signed_doc/src/metadata/document_type.rs @@ -0,0 +1,29 @@ +//! Document Type. +use std::fmt::{Display, Formatter}; + +use super::UuidV4; + +/// Catalyst Document Type. +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, serde::Deserialize)] +#[serde(from = "UuidV4")] +pub struct DocumentType(UuidV4); + +impl DocumentType { + /// Returns the `uuid::Uuid` type. + #[must_use] + pub fn uuid(&self) -> uuid::Uuid { + self.0.uuid() + } +} + +impl Display for DocumentType { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + write!(f, "{}", self.0) + } +} + +impl From for DocumentType { + fn from(value: UuidV4) -> Self { + Self(value) + } +} diff --git a/rust/signed_doc/src/metadata/document_version.rs b/rust/signed_doc/src/metadata/document_version.rs new file mode 100644 index 00000000000..b390e147eed --- /dev/null +++ b/rust/signed_doc/src/metadata/document_version.rs @@ -0,0 +1,28 @@ +//! Document Version. +use std::fmt::{Display, Formatter}; + +use super::UuidV7; + +/// Catalyst Document Version. +#[derive(Copy, Clone, Debug, PartialEq, PartialOrd, serde::Deserialize)] +pub struct DocumentVersion(UuidV7); + +impl DocumentVersion { + /// Returns the `uuid::Uuid` type. + #[must_use] + pub fn uuid(&self) -> uuid::Uuid { + self.0.uuid() + } +} + +impl Display for DocumentVersion { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { + write!(f, "{}", self.0) + } +} + +impl From for DocumentVersion { + fn from(value: UuidV7) -> Self { + Self(value) + } +} diff --git a/rust/signed_doc/src/metadata/mod.rs b/rust/signed_doc/src/metadata/mod.rs new file mode 100644 index 00000000000..2a571972c06 --- /dev/null +++ b/rust/signed_doc/src/metadata/mod.rs @@ -0,0 +1,244 @@ +//! Catalyst Signed Document Metadata. +use std::fmt::{Display, Formatter}; + +mod additional_fields; +mod content_encoding; +mod content_type; +mod document_id; +mod document_ref; +mod document_type; +mod document_version; + +pub use additional_fields::AdditionalFields; +use anyhow::anyhow; +pub use catalyst_types::uuid::{V4 as UuidV4, V7 as UuidV7}; +pub use content_encoding::ContentEncoding; +pub use content_type::ContentType; +use coset::CborSerializable; +pub use document_id::DocumentId; +pub use document_ref::DocumentRef; +pub use document_type::DocumentType; +pub use document_version::DocumentVersion; + +/// Catalyst Signed Document Content Encoding Key. +const CONTENT_ENCODING_KEY: &str = "Content-Encoding"; + +/// Document Metadata. +/// +/// These values are extracted from the COSE Sign protected header. +#[derive(Debug, serde::Deserialize)] +pub struct Metadata { + /// Document Type `UUIDv4`. + #[serde(rename = "type")] + doc_type: DocumentType, + /// Document ID `UUIDv7`. + id: DocumentId, + /// Document Version `UUIDv7`. + ver: DocumentVersion, + /// Document Payload Content Type. + #[serde(rename = "content-type")] + content_type: ContentType, + /// Document Payload Content Encoding. + #[serde(rename = "content-encoding")] + content_encoding: Option, + /// Additional Metadata Fields. + #[serde(flatten)] + extra: AdditionalFields, +} + +impl Metadata { + /// Return Document Type `UUIDv4`. + #[must_use] + pub fn doc_type(&self) -> uuid::Uuid { + self.doc_type.uuid() + } + + /// Return Document ID `UUIDv7`. + #[must_use] + pub fn doc_id(&self) -> uuid::Uuid { + self.id.uuid() + } + + /// Return Document Version `UUIDv7`. + #[must_use] + pub fn doc_ver(&self) -> uuid::Uuid { + self.ver.uuid() + } + + /// Returns the Document Content Type, if any. + #[must_use] + pub fn content_type(&self) -> ContentType { + self.content_type + } + + /// Returns the Document Content Encoding, if any. + #[must_use] + pub fn content_encoding(&self) -> Option { + self.content_encoding + } + + /// Return reference to additional metadata fields. + #[must_use] + pub fn extra(&self) -> &AdditionalFields { + &self.extra + } +} + +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, " content_type: {}", self.content_type)?; + writeln!(f, " content_encoding: {:?}", self.content_encoding)?; + writeln!(f, " additional_fields: {:?},", self.extra)?; + writeln!(f, "}}") + } +} + +impl TryFrom<&coset::ProtectedHeader> for Metadata { + type Error = crate::error::Error; + + fn try_from(protected: &coset::ProtectedHeader) -> Result { + let mut errors = Vec::new(); + + 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), + Err(e) => errors.push(anyhow!("Invalid Document Content-Type: {e}")), + } + } else { + errors.push(anyhow!( + "Invalid COSE protected header, missing Content-Type field" + )); + } + + 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), + Err(e) => errors.push(anyhow!("Invalid Document Content Encoding: {e}")), + } + } else { + errors.push(anyhow!( + "Invalid COSE protected header, missing Content-Encoding field" + )); + } + + let mut doc_type: Option = None; + if let Some(value) = cose_protected_header_find(protected, |key| { + key == &coset::Label::Text("type".to_string()) + }) { + match decode_cbor_uuid(value.clone()) { + Ok(uuid) => doc_type = Some(uuid), + Err(e) => errors.push(anyhow!("Invalid document type UUID: {e}")), + } + } else { + errors.push(anyhow!( + "Invalid COSE protected header, missing `type` field" + )); + } + + let mut id: Option = None; + if let Some(value) = cose_protected_header_find(protected, |key| { + key == &coset::Label::Text("id".to_string()) + }) { + match decode_cbor_uuid(value.clone()) { + Ok(uuid) => id = Some(uuid), + Err(e) => errors.push(anyhow!("Invalid document ID UUID: {e}")), + } + } else { + errors.push(anyhow!("Invalid COSE protected header, missing `id` field")); + } + + let mut ver: Option = None; + if let Some(value) = cose_protected_header_find(protected, |key| { + key == &coset::Label::Text("ver".to_string()) + }) { + match decode_cbor_uuid(value.clone()) { + Ok(uuid) => ver = Some(uuid), + Err(e) => errors.push(anyhow!("Invalid document version UUID: {e}")), + } + } else { + errors.push(anyhow!( + "Invalid COSE protected header, missing `ver` field" + )); + } + + let extra = AdditionalFields::try_from(protected).map_or_else( + |e| { + errors.extend(e.0 .0); + None + }, + Some, + ); + + 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 ver < id { + errors.push(anyhow!( + "Document Version {ver} cannot be smaller than Document ID {id}", + )); + return Err(crate::error::Error(errors.into())); + } + + Ok(Self { + doc_type: doc_type.into(), + id: id.into(), + ver: ver.into(), + content_encoding, + content_type, + extra, + }) + }, + _ => Err(crate::error::Error(errors.into())), + } + } +} + +/// 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) +} + +/// Convert from `minicbor` into `coset::cbor::Value`. +pub(crate) fn encode_cbor_value>( + value: T, +) -> anyhow::Result { + let mut cbor_bytes = Vec::new(); + minicbor::encode(value, &mut cbor_bytes) + .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}")) +} + +/// Convert `coset::cbor::Value` into `UuidV4`. +pub(crate) fn decode_cbor_uuid minicbor::decode::Decode<'a, ()> + From>( + value: coset::cbor::Value, +) -> anyhow::Result { + match value.to_vec() { + Ok(cbor_value) => { + minicbor::decode(&cbor_value).map_err(|e| anyhow!("Invalid UUID, err: {e}")) + }, + Err(e) => anyhow::bail!("Invalid CBOR value, err: {e}"), + } +} diff --git a/rust/signed_doc/src/signature/mod.rs b/rust/signed_doc/src/signature/mod.rs new file mode 100644 index 00000000000..37c23fab559 --- /dev/null +++ b/rust/signed_doc/src/signature/mod.rs @@ -0,0 +1,54 @@ +//! Catalyst Signed Document COSE Signature information. + +pub use catalyst_types::kid_uri::KidUri; +use coset::CoseSignature; + +/// Catalyst Signed Document COSE Signature. +#[derive(Debug)] +pub struct Signature { + /// Key ID + kid: KidUri, + /// COSE Signature + #[allow(dead_code)] + signature: CoseSignature, +} + +/// List of Signatures. +#[derive(Debug)] +pub struct Signatures(Vec); + +impl Signatures { + /// List of signature Key IDs. + pub fn kids(&self) -> Vec { + self.0.iter().map(|sig| sig.kid.clone()).collect() + } +} + +impl TryFrom<&Vec> for Signatures { + type Error = crate::error::Error; + + fn try_from(value: &Vec) -> Result { + let mut signatures = Vec::new(); + let mut errors = Vec::new(); + value + .iter() + .cloned() + .enumerate() + .for_each(|(idx, signature)| { + match KidUri::try_from(signature.protected.header.key_id.as_ref()) { + Ok(kid) => signatures.push(Signature { kid, signature }), + Err(e) => { + errors.push(anyhow::anyhow!( + "Signature at index {idx} has valid Catalyst Key Id: {e}" + )); + }, + } + }); + + if errors.is_empty() { + Ok(Signatures(signatures)) + } else { + Err(errors.into()) + } + } +}