• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

input-output-hk / catalyst-libs / 15699085884

17 Jun 2025 05:37AM UTC coverage: 65.743%. First build
15699085884

Pull #353

github

web-flow
Merge e00afc78a into fe82adb0a
Pull Request #353: feat(rust/signed-doc): Catalyst signed document encoding using minicbor

152 of 204 new or added lines in 14 files covered. (74.51%)

11409 of 17354 relevant lines covered (65.74%)

2284.46 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.28
/rust/signed_doc/src/validator/mod.rs
1
//! Catalyst Signed Documents validation logic
2

3
pub(crate) mod rules;
4
pub(crate) mod utils;
5

6
use std::{
7
    collections::HashMap,
8
    sync::LazyLock,
9
    time::{Duration, SystemTime},
10
};
11

12
use anyhow::Context;
13
use catalyst_types::{catalyst_id::role_index::RoleId, problem_report::ProblemReport};
14
use rules::{
15
    ContentEncodingRule, ContentRule, ContentSchema, ContentTypeRule, ParametersRule, RefRule,
16
    ReplyRule, Rules, SectionRule, SignatureKidRule,
17
};
18

19
use crate::{
20
    doc_types::{
21
        deprecated::{
22
            CATEGORY_DOCUMENT_UUID_TYPE, COMMENT_TEMPLATE_UUID_TYPE, PROPOSAL_TEMPLATE_UUID_TYPE,
23
        },
24
        COMMENT_UUID_TYPE, PROPOSAL_ACTION_DOC, PROPOSAL_COMMENT_DOC, PROPOSAL_DOC_TYPE,
25
        PROPOSAL_UUID_TYPE,
26
    },
27
    metadata::DocType,
28
    providers::{CatalystSignedDocumentProvider, VerifyingKeyProvider},
29
    signature::{tbs_data, Signature},
30
    CatalystSignedDocument, ContentEncoding, ContentType,
31
};
32

33
/// A table representing a full set or validation rules per document id.
34
static DOCUMENT_RULES: LazyLock<HashMap<DocType, Rules>> = LazyLock::new(document_rules_init);
35

36
/// Returns an `DocType` from the provided argument.
37
/// Reduce redundant conversion.
38
/// This function should be used for hardcoded values, panic if conversion fail.
39
#[allow(clippy::expect_used)]
40
pub(crate) fn expect_doc_type<T>(t: T) -> DocType
91✔
41
where
91✔
42
    T: TryInto<DocType>,
91✔
43
    T::Error: std::fmt::Debug,
91✔
44
{
91✔
45
    t.try_into().expect("Failed to convert to DocType")
91✔
46
}
91✔
47

48
/// `DOCUMENT_RULES` initialization function
49
#[allow(clippy::expect_used)]
50
fn document_rules_init() -> HashMap<DocType, Rules> {
13✔
51
    let mut document_rules_map = HashMap::new();
13✔
52

13✔
53
    let proposal_document_rules = Rules {
13✔
54
        content_type: ContentTypeRule {
13✔
55
            exp: ContentType::Json,
13✔
56
        },
13✔
57
        content_encoding: ContentEncodingRule {
13✔
58
            exp: ContentEncoding::Brotli,
13✔
59
            optional: false,
13✔
60
        },
13✔
61
        content: ContentRule::Templated {
13✔
62
            exp_template_type: expect_doc_type(PROPOSAL_TEMPLATE_UUID_TYPE),
13✔
63
        },
13✔
64
        parameters: ParametersRule::Specified {
13✔
65
            exp_parameters_type: expect_doc_type(CATEGORY_DOCUMENT_UUID_TYPE),
13✔
66
            optional: true,
13✔
67
        },
13✔
68
        doc_ref: RefRule::NotSpecified,
13✔
69
        reply: ReplyRule::NotSpecified,
13✔
70
        section: SectionRule::NotSpecified,
13✔
71
        kid: SignatureKidRule {
13✔
72
            exp: &[RoleId::Proposer],
13✔
73
        },
13✔
74
    };
13✔
75

13✔
76
    document_rules_map.insert(PROPOSAL_DOC_TYPE.clone(), proposal_document_rules);
13✔
77

13✔
78
    let comment_document_rules = Rules {
13✔
79
        content_type: ContentTypeRule {
13✔
80
            exp: ContentType::Json,
13✔
81
        },
13✔
82
        content_encoding: ContentEncodingRule {
13✔
83
            exp: ContentEncoding::Brotli,
13✔
84
            optional: false,
13✔
85
        },
13✔
86
        content: ContentRule::Templated {
13✔
87
            exp_template_type: expect_doc_type(COMMENT_TEMPLATE_UUID_TYPE),
13✔
88
        },
13✔
89
        doc_ref: RefRule::Specified {
13✔
90
            exp_ref_type: expect_doc_type(PROPOSAL_UUID_TYPE),
13✔
91
            optional: false,
13✔
92
        },
13✔
93
        reply: ReplyRule::Specified {
13✔
94
            exp_reply_type: expect_doc_type(COMMENT_UUID_TYPE),
13✔
95
            optional: true,
13✔
96
        },
13✔
97
        section: SectionRule::Specified { optional: true },
13✔
98
        parameters: ParametersRule::NotSpecified,
13✔
99
        kid: SignatureKidRule {
13✔
100
            exp: &[RoleId::Role0],
13✔
101
        },
13✔
102
    };
13✔
103
    document_rules_map.insert(PROPOSAL_COMMENT_DOC.clone(), comment_document_rules);
13✔
104

13✔
105
    let proposal_action_json_schema = jsonschema::options()
13✔
106
        .with_draft(jsonschema::Draft::Draft7)
13✔
107
        .build(
13✔
108
            &serde_json::from_str(include_str!(
13✔
109
                "./../../../../specs/signed_docs/docs/payload_schemas/proposal_submission_action.schema.json"
13✔
110
            ))
13✔
111
            .expect("Must be a valid json file"),
13✔
112
        )
13✔
113
        .expect("Must be a valid json scheme file");
13✔
114
    let proposal_submission_action_rules = Rules {
13✔
115
        content_type: ContentTypeRule {
13✔
116
            exp: ContentType::Json,
13✔
117
        },
13✔
118
        content_encoding: ContentEncodingRule {
13✔
119
            exp: ContentEncoding::Brotli,
13✔
120
            optional: false,
13✔
121
        },
13✔
122
        content: ContentRule::Static(ContentSchema::Json(proposal_action_json_schema)),
13✔
123
        parameters: ParametersRule::Specified {
13✔
124
            exp_parameters_type: expect_doc_type(CATEGORY_DOCUMENT_UUID_TYPE),
13✔
125
            optional: true,
13✔
126
        },
13✔
127
        doc_ref: RefRule::Specified {
13✔
128
            exp_ref_type: expect_doc_type(PROPOSAL_UUID_TYPE),
13✔
129
            optional: false,
13✔
130
        },
13✔
131
        reply: ReplyRule::NotSpecified,
13✔
132
        section: SectionRule::NotSpecified,
13✔
133
        kid: SignatureKidRule {
13✔
134
            exp: &[RoleId::Proposer],
13✔
135
        },
13✔
136
    };
13✔
137

13✔
138
    document_rules_map.insert(
13✔
139
        PROPOSAL_ACTION_DOC.clone(),
13✔
140
        proposal_submission_action_rules,
13✔
141
    );
13✔
142

13✔
143
    document_rules_map
13✔
144
}
13✔
145

146
/// A comprehensive document type based validation of the `CatalystSignedDocument`.
147
/// Includes time based validation of the `id` and `ver` fields based on the provided
148
/// `future_threshold` and `past_threshold` threshold values (in seconds).
149
/// Return true if it is valid, otherwise return false.
150
///
151
/// # Errors
152
/// If `provider` returns error, fails fast throwing that error.
153
pub async fn validate<Provider>(
14✔
154
    doc: &CatalystSignedDocument, provider: &Provider,
14✔
155
) -> anyhow::Result<bool>
14✔
156
where Provider: CatalystSignedDocumentProvider {
14✔
157
    let Ok(doc_type) = doc.doc_type() else {
14✔
158
        doc.report().missing_field(
×
159
            "type",
×
160
            "Can't get a document type during the validation process",
×
161
        );
×
162
        return Ok(false);
×
163
    };
164

165
    if !validate_id_and_ver(doc, provider)? {
14✔
166
        return Ok(false);
×
167
    }
14✔
168

169
    let Some(rules) = DOCUMENT_RULES.get(doc_type) else {
14✔
170
        doc.report().invalid_value(
×
171
            "`type`",
×
172
            &doc.doc_type()?.to_string(),
×
173
            "Must be a known document type value",
×
174
            "Unsupported document type",
×
175
        );
×
176
        return Ok(false);
×
177
    };
178
    rules.check(doc, provider).await
14✔
179
}
14✔
180

181
/// Validates document `id` and `ver` fields on the timestamps:
182
/// 1. document `ver` cannot be smaller than document id field
183
/// 2. If `provider.future_threshold()` not `None`, document `id` cannot be too far in the
184
///    future (`future_threshold` arg) from `SystemTime::now()` based on the provide
185
///    threshold
186
/// 3. If `provider.future_threshold()` not `None`, document `id` cannot be too far behind
187
///    (`past_threshold` arg) from `SystemTime::now()` based on the provide threshold
188
fn validate_id_and_ver<Provider>(
18✔
189
    doc: &CatalystSignedDocument, provider: &Provider,
18✔
190
) -> anyhow::Result<bool>
18✔
191
where Provider: CatalystSignedDocumentProvider {
18✔
192
    let id = doc.doc_id().ok();
18✔
193
    let ver = doc.doc_ver().ok();
18✔
194
    if id.is_none() {
18✔
195
        doc.report().missing_field(
×
196
            "id",
×
197
            "Can't get a document id during the validation process",
×
198
        );
×
199
    }
18✔
200
    if ver.is_none() {
18✔
201
        doc.report().missing_field(
×
202
            "ver",
×
203
            "Can't get a document ver during the validation process",
×
204
        );
×
205
    }
18✔
206
    match (id, ver) {
18✔
207
        (Some(id), Some(ver)) => {
18✔
208
            let mut is_valid = true;
18✔
209
            if ver < id {
18✔
210
                doc.report().invalid_value(
1✔
211
                    "ver",
1✔
212
                    &ver.to_string(),
1✔
213
                    "ver < id",
1✔
214
                    &format!("Document Version {ver} cannot be smaller than Document ID {id}"),
1✔
215
                );
1✔
216
                is_valid = false;
1✔
217
            }
17✔
218

219
            let (ver_time_secs, ver_time_nanos) = ver
18✔
220
                .uuid()
18✔
221
                .get_timestamp()
18✔
222
                .ok_or(anyhow::anyhow!("Document ver field must be a UUIDv7"))?
18✔
223
                .to_unix();
18✔
224

225
            let Some(ver_time) =
18✔
226
                SystemTime::UNIX_EPOCH.checked_add(Duration::new(ver_time_secs, ver_time_nanos))
18✔
227
            else {
228
                doc.report().invalid_value(
×
229
                    "ver",
×
230
                    &ver.to_string(),
×
231
                    "Must a valid duration since `UNIX_EPOCH`",
×
232
                    "Cannot instantiate a valid `SystemTime` value from the provided `ver` field timestamp.",
×
233
                );
×
234
                return Ok(false);
×
235
            };
236

237
            let now = SystemTime::now();
18✔
238

239
            if let Ok(version_age) = ver_time.duration_since(now) {
18✔
240
                // `now` is earlier than `ver_time`
241
                if let Some(future_threshold) = provider.future_threshold() {
1✔
242
                    if version_age > future_threshold {
1✔
243
                        doc.report().invalid_value(
1✔
244
                        "ver",
1✔
245
                        &ver.to_string(),
1✔
246
                        "ver < now + future_threshold",
1✔
247
                        &format!("Document Version timestamp {id} cannot be too far in future (threshold: {future_threshold:?}) from now: {now:?}"),
1✔
248
                    );
1✔
249
                        is_valid = false;
1✔
250
                    }
1✔
251
                }
×
252
            } else {
253
                // `ver_time` is earlier than `now`
254
                let version_age = now
17✔
255
                    .duration_since(ver_time)
17✔
256
                    .context("BUG! `ver_time` must be earlier than `now` at this place")?;
17✔
257

258
                if let Some(past_threshold) = provider.past_threshold() {
17✔
259
                    if version_age > past_threshold {
17✔
260
                        doc.report().invalid_value(
1✔
261
                        "ver",
1✔
262
                        &ver.to_string(),
1✔
263
                        "ver > now - past_threshold",
1✔
264
                        &format!("Document Version timestamp {id} cannot be too far behind (threshold: {past_threshold:?}) from now: {now:?}",),
1✔
265
                    );
1✔
266
                        is_valid = false;
1✔
267
                    }
16✔
268
                }
×
269
            }
270

271
            Ok(is_valid)
18✔
272
        },
273

274
        _ => Ok(false),
×
275
    }
276
}
18✔
277

278
/// Verify document signatures.
279
/// Return true if all signatures are valid, otherwise return false.
280
///
281
/// # Errors
282
/// If `provider` returns error, fails fast throwing that error.
283
pub async fn validate_signatures(
13✔
284
    doc: &CatalystSignedDocument, provider: &impl VerifyingKeyProvider,
13✔
285
) -> anyhow::Result<bool> {
13✔
286
    if doc.signatures().is_empty() {
13✔
287
        doc.report().other(
1✔
288
            "Catalyst Signed Document is unsigned",
1✔
289
            "During Catalyst Signed Document signature validation",
1✔
290
        );
1✔
291
        return Ok(false);
1✔
292
    }
12✔
293

12✔
294
    let sign_rules = doc
12✔
295
        .signatures()
12✔
296
        .iter()
12✔
297
        .map(|sign| validate_signature(doc, sign, provider, doc.report()));
20✔
298

299
    let res = futures::future::join_all(sign_rules)
12✔
300
        .await
12✔
301
        .into_iter()
12✔
302
        .collect::<anyhow::Result<Vec<_>>>()?
12✔
303
        .iter()
12✔
304
        .all(|res| *res);
16✔
305

12✔
306
    Ok(res)
12✔
307
}
13✔
308

309
/// A single signature validation function
310
async fn validate_signature<Provider>(
20✔
311
    doc: &CatalystSignedDocument, sign: &Signature, provider: &Provider, report: &ProblemReport,
20✔
312
) -> anyhow::Result<bool>
20✔
313
where Provider: VerifyingKeyProvider {
20✔
314
    let kid = sign.kid();
20✔
315

316
    let Some(pk) = provider.try_get_key(kid).await? else {
20✔
317
        report.other(
8✔
318
            &format!("Missing public key for {kid}."),
8✔
319
            "During public key extraction",
8✔
320
        );
8✔
321
        return Ok(false);
8✔
322
    };
323

324
    let Ok(tbs_data) = tbs_data(kid, doc.doc_meta(), doc.doc_content()) else {
12✔
NEW
325
        doc.report().other(
×
NEW
326
            "Cannot build a COSE to be signed data",
×
NEW
327
            "During creating COSE to be signed data",
×
NEW
328
        );
×
NEW
329
        return Ok(false);
×
330
    };
331

332
    let Ok(signature_bytes) = sign.signature().try_into() else {
12✔
333
        report.invalid_value(
×
334
            "cose signature",
×
NEW
335
            &format!("{}", sign.signature().len()),
×
336
            &format!("must be {}", ed25519_dalek::Signature::BYTE_SIZE),
×
337
            "During encoding cose signature to bytes",
×
338
        );
×
339
        return Ok(false);
×
340
    };
341

342
    let signature = ed25519_dalek::Signature::from_bytes(signature_bytes);
12✔
343
    if pk.verify_strict(&tbs_data, &signature).is_err() {
12✔
344
        report.functional_validation(
×
345
            &format!("Verification failed for signature with Key ID {kid}"),
×
346
            "During signature validation with verifying key",
×
347
        );
×
348
        return Ok(false);
×
349
    }
12✔
350

12✔
351
    Ok(true)
12✔
352
}
20✔
353

354
#[cfg(test)]
355
mod tests {
356
    use std::time::SystemTime;
357

358
    use uuid::{Timestamp, Uuid};
359

360
    use crate::{
361
        providers::{tests::TestCatalystSignedDocumentProvider, CatalystSignedDocumentProvider},
362
        validator::{document_rules_init, validate_id_and_ver},
363
        Builder, UuidV7,
364
    };
365

366
    #[test]
367
    fn document_id_and_ver_test() {
1✔
368
        let provider = TestCatalystSignedDocumentProvider::default();
1✔
369
        let now = SystemTime::now()
1✔
370
            .duration_since(SystemTime::UNIX_EPOCH)
1✔
371
            .unwrap()
1✔
372
            .as_secs();
1✔
373

1✔
374
        let uuid_v7 = UuidV7::new();
1✔
375
        let doc = Builder::new()
1✔
376
            .with_json_metadata(serde_json::json!({
1✔
377
                "id": uuid_v7.to_string(),
1✔
378
                "ver": uuid_v7.to_string()
1✔
379
            }))
1✔
380
            .unwrap()
1✔
381
            .build();
1✔
382

1✔
383
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
384
        assert!(is_valid);
1✔
385

386
        let ver = Uuid::new_v7(Timestamp::from_unix_time(now - 1, 0, 0, 0));
1✔
387
        let id = Uuid::new_v7(Timestamp::from_unix_time(now + 1, 0, 0, 0));
1✔
388
        assert!(ver < id);
1✔
389
        let doc = Builder::new()
1✔
390
            .with_json_metadata(serde_json::json!({
1✔
391
                "id": id.to_string(),
1✔
392
                "ver": ver.to_string()
1✔
393
            }))
1✔
394
            .unwrap()
1✔
395
            .build();
1✔
396

1✔
397
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
398
        assert!(!is_valid);
1✔
399

400
        let to_far_in_past = Uuid::new_v7(Timestamp::from_unix_time(
1✔
401
            now - provider.past_threshold().unwrap().as_secs() - 1,
1✔
402
            0,
1✔
403
            0,
1✔
404
            0,
1✔
405
        ));
1✔
406
        let doc = Builder::new()
1✔
407
            .with_json_metadata(serde_json::json!({
1✔
408
                "id": to_far_in_past.to_string(),
1✔
409
                "ver": to_far_in_past.to_string()
1✔
410
            }))
1✔
411
            .unwrap()
1✔
412
            .build();
1✔
413

1✔
414
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
415
        assert!(!is_valid);
1✔
416

417
        let to_far_in_future = Uuid::new_v7(Timestamp::from_unix_time(
1✔
418
            now + provider.future_threshold().unwrap().as_secs() + 1,
1✔
419
            0,
1✔
420
            0,
1✔
421
            0,
1✔
422
        ));
1✔
423
        let doc = Builder::new()
1✔
424
            .with_json_metadata(serde_json::json!({
1✔
425
                "id": to_far_in_future.to_string(),
1✔
426
                "ver": to_far_in_future.to_string()
1✔
427
            }))
1✔
428
            .unwrap()
1✔
429
            .build();
1✔
430

1✔
431
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
432
        assert!(!is_valid);
1✔
433
    }
1✔
434

435
    #[test]
436
    fn document_rules_init_test() {
1✔
437
        document_rules_init();
1✔
438
    }
1✔
439
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc