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

input-output-hk / catalyst-libs / 17068937103

19 Aug 2025 12:02PM UTC coverage: 66.968% (+0.07%) from 66.9%
17068937103

Pull #495

github

web-flow
Merge 68ce374a1 into 9d001c25c
Pull Request #495: feat(docs): Add Optional form sections

12592 of 18803 relevant lines covered (66.97%)

3115.89 hits per line

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

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

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

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

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

20
use crate::{
21
    doc_types::{
22
        BRAND_PARAMETERS, CAMPAIGN_PARAMETERS, CATEGORY_PARAMETERS, PROPOSAL, PROPOSAL_COMMENT,
23
        PROPOSAL_COMMENT_FORM_TEMPLATE, PROPOSAL_FORM_TEMPLATE, PROPOSAL_SUBMISSION_ACTION,
24
    },
25
    metadata::DocType,
26
    providers::{CatalystSignedDocumentProvider, VerifyingKeyProvider},
27
    signature::{tbs_data, Signature},
28
    CatalystSignedDocument, ContentEncoding, ContentType,
29
};
30

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

34
/// Proposal
35
/// Require field: type, id, ver, template, parameters
36
/// <https://input-output-hk.github.io/catalyst-libs/architecture/08_concepts/signed_doc/docs/proposal/>
37
fn proposal_rule() -> Rules {
15✔
38
    // Parameter can be either brand, campaign or category
39
    let parameters = vec![
15✔
40
        BRAND_PARAMETERS.clone(),
15✔
41
        CAMPAIGN_PARAMETERS.clone(),
15✔
42
        CATEGORY_PARAMETERS.clone(),
15✔
43
    ];
44
    Rules {
15✔
45
        content_type: ContentTypeRule {
15✔
46
            exp: ContentType::Json,
15✔
47
        },
15✔
48
        content_encoding: ContentEncodingRule {
15✔
49
            exp: ContentEncoding::Brotli,
15✔
50
            optional: false,
15✔
51
        },
15✔
52
        content: ContentRule::Templated {
15✔
53
            exp_template_type: PROPOSAL_FORM_TEMPLATE.clone(),
15✔
54
        },
15✔
55
        parameters: ParametersRule::Specified {
15✔
56
            exp_parameters_type: parameters.clone(),
15✔
57
            optional: false,
15✔
58
        },
15✔
59
        doc_ref: RefRule::NotSpecified,
15✔
60
        reply: ReplyRule::NotSpecified,
15✔
61
        section: SectionRule::NotSpecified,
15✔
62
        kid: SignatureKidRule {
15✔
63
            exp: &[RoleId::Proposer],
15✔
64
        },
15✔
65
    }
15✔
66
}
15✔
67

68
/// Proposal Comment
69
/// Require field: type, id, ver, ref, template, parameters
70
/// <https://input-output-hk.github.io/catalyst-libs/architecture/08_concepts/signed_doc/docs/proposal_comment_template/>
71
fn proposal_comment_rule() -> Rules {
15✔
72
    // Parameter can be either brand, campaign or category
73
    let parameters = vec![
15✔
74
        BRAND_PARAMETERS.clone(),
15✔
75
        CAMPAIGN_PARAMETERS.clone(),
15✔
76
        CATEGORY_PARAMETERS.clone(),
15✔
77
    ];
78
    Rules {
15✔
79
        content_type: ContentTypeRule {
15✔
80
            exp: ContentType::Json,
15✔
81
        },
15✔
82
        content_encoding: ContentEncodingRule {
15✔
83
            exp: ContentEncoding::Brotli,
15✔
84
            optional: false,
15✔
85
        },
15✔
86
        content: ContentRule::Templated {
15✔
87
            exp_template_type: PROPOSAL_COMMENT_FORM_TEMPLATE.clone(),
15✔
88
        },
15✔
89
        doc_ref: RefRule::Specified {
15✔
90
            exp_ref_types: vec![PROPOSAL.clone()],
15✔
91
            optional: false,
15✔
92
        },
15✔
93
        reply: ReplyRule::Specified {
15✔
94
            exp_reply_type: PROPOSAL_COMMENT.clone(),
15✔
95
            optional: true,
15✔
96
        },
15✔
97
        section: SectionRule::NotSpecified,
15✔
98
        parameters: ParametersRule::Specified {
15✔
99
            exp_parameters_type: parameters.clone(),
15✔
100
            optional: false,
15✔
101
        },
15✔
102
        kid: SignatureKidRule {
15✔
103
            exp: &[RoleId::Role0],
15✔
104
        },
15✔
105
    }
15✔
106
}
15✔
107

108
/// Proposal Submission Action
109
/// Require fields: type, id, ver, ref, parameters
110
/// <https://input-output-hk.github.io/catalyst-libs/architecture/08_concepts/signed_doc/docs/proposal_submission_action/>
111
#[allow(clippy::expect_used)]
112
fn proposal_submission_action_rule() -> Rules {
15✔
113
    // Parameter can be either brand, campaign or category
114
    let parameters = vec![
15✔
115
        BRAND_PARAMETERS.clone(),
15✔
116
        CAMPAIGN_PARAMETERS.clone(),
15✔
117
        CATEGORY_PARAMETERS.clone(),
15✔
118
    ];
119

120
    let proposal_action_json_schema_content = &serde_json::from_str(include_str!(
15✔
121
        "./../../../../specs/definitions/signed_docs/docs/payload_schemas/proposal_submission_action.schema.json"
15✔
122
    ))
15✔
123
    .expect("Must be a valid json file");
15✔
124

125
    let proposal_action_json_schema =
15✔
126
        json_schema::JsonSchema::try_from(proposal_action_json_schema_content)
15✔
127
            .expect("Must be a valid json scheme file");
15✔
128

129
    Rules {
15✔
130
        content_type: ContentTypeRule {
15✔
131
            exp: ContentType::Json,
15✔
132
        },
15✔
133
        content_encoding: ContentEncodingRule {
15✔
134
            exp: ContentEncoding::Brotli,
15✔
135
            optional: false,
15✔
136
        },
15✔
137
        content: ContentRule::Static(ContentSchema::Json(proposal_action_json_schema)),
15✔
138
        parameters: ParametersRule::Specified {
15✔
139
            exp_parameters_type: parameters,
15✔
140
            optional: false,
15✔
141
        },
15✔
142
        doc_ref: RefRule::Specified {
15✔
143
            exp_ref_types: vec![PROPOSAL.clone()],
15✔
144
            optional: false,
15✔
145
        },
15✔
146
        reply: ReplyRule::NotSpecified,
15✔
147
        section: SectionRule::NotSpecified,
15✔
148
        kid: SignatureKidRule {
15✔
149
            exp: &[RoleId::Proposer],
15✔
150
        },
15✔
151
    }
15✔
152
}
15✔
153

154
/// `DOCUMENT_RULES` initialization function
155
fn document_rules_init() -> HashMap<DocType, Arc<Rules>> {
15✔
156
    let mut document_rules_map = HashMap::new();
15✔
157

158
    let proposal_rules = Arc::new(proposal_rule());
15✔
159
    let comment_rules = Arc::new(proposal_comment_rule());
15✔
160
    let action_rules = Arc::new(proposal_submission_action_rule());
15✔
161

162
    document_rules_map.insert(PROPOSAL.clone(), Arc::clone(&proposal_rules));
15✔
163
    document_rules_map.insert(PROPOSAL_COMMENT.clone(), Arc::clone(&comment_rules));
15✔
164
    document_rules_map.insert(
15✔
165
        PROPOSAL_SUBMISSION_ACTION.clone(),
15✔
166
        Arc::clone(&action_rules),
15✔
167
    );
168

169
    document_rules_map
15✔
170
}
15✔
171

172
/// A comprehensive document type based validation of the `CatalystSignedDocument`.
173
/// Includes time based validation of the `id` and `ver` fields based on the provided
174
/// `future_threshold` and `past_threshold` threshold values (in seconds).
175
/// Return true if it is valid, otherwise return false.
176
///
177
/// # Errors
178
/// If `provider` returns error, fails fast throwing that error.
179
pub async fn validate<Provider>(
14✔
180
    doc: &CatalystSignedDocument,
14✔
181
    provider: &Provider,
14✔
182
) -> anyhow::Result<bool>
14✔
183
where
14✔
184
    Provider: CatalystSignedDocumentProvider,
14✔
185
{
14✔
186
    let Ok(doc_type) = doc.doc_type() else {
14✔
187
        doc.report().missing_field(
×
188
            "type",
×
189
            "Can't get a document type during the validation process",
×
190
        );
191
        return Ok(false);
×
192
    };
193

194
    if !validate_id_and_ver(doc, provider)? {
14✔
195
        return Ok(false);
×
196
    }
14✔
197

198
    let Some(rules) = DOCUMENT_RULES.get(doc_type) else {
14✔
199
        doc.report().invalid_value(
×
200
            "`type`",
×
201
            &doc.doc_type()?.to_string(),
×
202
            "Must be a known document type value",
×
203
            "Unsupported document type",
×
204
        );
205
        return Ok(false);
×
206
    };
207
    rules.check(doc, provider).await
14✔
208
}
14✔
209

210
/// Validates document `id` and `ver` fields on the timestamps:
211
/// 1. document `ver` cannot be smaller than document id field
212
/// 2. If `provider.future_threshold()` not `None`, document `id` cannot be too far in the
213
///    future (`future_threshold` arg) from `SystemTime::now()` based on the provide
214
///    threshold
215
/// 3. If `provider.future_threshold()` not `None`, document `id` cannot be too far behind
216
///    (`past_threshold` arg) from `SystemTime::now()` based on the provide threshold
217
fn validate_id_and_ver<Provider>(
18✔
218
    doc: &CatalystSignedDocument,
18✔
219
    provider: &Provider,
18✔
220
) -> anyhow::Result<bool>
18✔
221
where
18✔
222
    Provider: CatalystSignedDocumentProvider,
18✔
223
{
224
    let id = doc.doc_id().ok();
18✔
225
    let ver = doc.doc_ver().ok();
18✔
226
    if id.is_none() {
18✔
227
        doc.report().missing_field(
×
228
            "id",
×
229
            "Can't get a document id during the validation process",
×
230
        );
×
231
    }
18✔
232
    if ver.is_none() {
18✔
233
        doc.report().missing_field(
×
234
            "ver",
×
235
            "Can't get a document ver during the validation process",
×
236
        );
×
237
    }
18✔
238
    match (id, ver) {
18✔
239
        (Some(id), Some(ver)) => {
18✔
240
            let mut is_valid = true;
18✔
241
            if ver < id {
18✔
242
                doc.report().invalid_value(
1✔
243
                    "ver",
1✔
244
                    &ver.to_string(),
1✔
245
                    "ver < id",
1✔
246
                    &format!("Document Version {ver} cannot be smaller than Document ID {id}"),
1✔
247
                );
1✔
248
                is_valid = false;
1✔
249
            }
17✔
250

251
            let (ver_time_secs, ver_time_nanos) = ver
18✔
252
                .uuid()
18✔
253
                .get_timestamp()
18✔
254
                .ok_or(anyhow::anyhow!("Document ver field must be a UUIDv7"))?
18✔
255
                .to_unix();
18✔
256

257
            let Some(ver_time) =
18✔
258
                SystemTime::UNIX_EPOCH.checked_add(Duration::new(ver_time_secs, ver_time_nanos))
18✔
259
            else {
260
                doc.report().invalid_value(
×
261
                    "ver",
×
262
                    &ver.to_string(),
×
263
                    "Must a valid duration since `UNIX_EPOCH`",
×
264
                    "Cannot instantiate a valid `SystemTime` value from the provided `ver` field timestamp.",
×
265
                );
266
                return Ok(false);
×
267
            };
268

269
            let now = SystemTime::now();
18✔
270

271
            if let Ok(version_age) = ver_time.duration_since(now) {
18✔
272
                // `now` is earlier than `ver_time`
273
                if let Some(future_threshold) = provider.future_threshold() {
1✔
274
                    if version_age > future_threshold {
1✔
275
                        doc.report().invalid_value(
1✔
276
                        "ver",
1✔
277
                        &ver.to_string(),
1✔
278
                        "ver < now + future_threshold",
1✔
279
                        &format!("Document Version timestamp {id} cannot be too far in future (threshold: {future_threshold:?}) from now: {now:?}"),
1✔
280
                    );
1✔
281
                        is_valid = false;
1✔
282
                    }
1✔
283
                }
×
284
            } else {
285
                // `ver_time` is earlier than `now`
286
                let version_age = now
17✔
287
                    .duration_since(ver_time)
17✔
288
                    .context("BUG! `ver_time` must be earlier than `now` at this place")?;
17✔
289

290
                if let Some(past_threshold) = provider.past_threshold() {
17✔
291
                    if version_age > past_threshold {
17✔
292
                        doc.report().invalid_value(
1✔
293
                        "ver",
1✔
294
                        &ver.to_string(),
1✔
295
                        "ver > now - past_threshold",
1✔
296
                        &format!("Document Version timestamp {id} cannot be too far behind (threshold: {past_threshold:?}) from now: {now:?}",),
1✔
297
                    );
1✔
298
                        is_valid = false;
1✔
299
                    }
16✔
300
                }
×
301
            }
302

303
            Ok(is_valid)
18✔
304
        },
305

306
        _ => Ok(false),
×
307
    }
308
}
18✔
309

310
/// Verify document signatures.
311
/// Return true if all signatures are valid, otherwise return false.
312
///
313
/// # Errors
314
/// If `provider` returns error, fails fast throwing that error.
315
pub async fn validate_signatures(
16✔
316
    doc: &CatalystSignedDocument,
16✔
317
    provider: &impl VerifyingKeyProvider,
16✔
318
) -> anyhow::Result<bool> {
16✔
319
    if doc.signatures().is_empty() {
16✔
320
        doc.report().other(
1✔
321
            "Catalyst Signed Document is unsigned",
1✔
322
            "During Catalyst Signed Document signature validation",
1✔
323
        );
324
        return Ok(false);
1✔
325
    }
15✔
326

327
    let sign_rules = doc
15✔
328
        .signatures()
15✔
329
        .iter()
15✔
330
        .map(|sign| validate_signature(doc, sign, provider, doc.report()));
24✔
331

332
    let res = futures::future::join_all(sign_rules)
15✔
333
        .await
15✔
334
        .into_iter()
15✔
335
        .collect::<anyhow::Result<Vec<_>>>()?
15✔
336
        .iter()
15✔
337
        .all(|res| *res);
15✔
338

339
    Ok(res)
15✔
340
}
16✔
341

342
/// A single signature validation function
343
async fn validate_signature<Provider>(
24✔
344
    doc: &CatalystSignedDocument,
24✔
345
    sign: &Signature,
24✔
346
    provider: &Provider,
24✔
347
    report: &ProblemReport,
24✔
348
) -> anyhow::Result<bool>
24✔
349
where
24✔
350
    Provider: VerifyingKeyProvider,
24✔
351
{
24✔
352
    let kid = sign.kid();
24✔
353

354
    let Some(pk) = provider.try_get_key(kid).await? else {
24✔
355
        report.other(
8✔
356
            &format!("Missing public key for {kid}."),
8✔
357
            "During public key extraction",
8✔
358
        );
359
        return Ok(false);
8✔
360
    };
361

362
    let tbs_data = tbs_data(kid, doc.doc_meta(), doc.content()).context("Probably a bug, cannot build CBOR COSE bytes for signature verification from the structurally valid COSE object.")?;
16✔
363

364
    let Ok(signature_bytes) = sign.signature().try_into() else {
16✔
365
        report.invalid_value(
×
366
            "cose signature",
×
367
            &format!("{}", sign.signature().len()),
×
368
            &format!("must be {}", ed25519_dalek::Signature::BYTE_SIZE),
×
369
            "During encoding cose signature to bytes",
×
370
        );
371
        return Ok(false);
×
372
    };
373

374
    let signature = ed25519_dalek::Signature::from_bytes(signature_bytes);
16✔
375
    if pk.verify_strict(&tbs_data, &signature).is_err() {
16✔
376
        report.functional_validation(
1✔
377
            &format!("Verification failed for signature with Key ID {kid}"),
1✔
378
            "During signature validation with verifying key",
1✔
379
        );
380
        return Ok(false);
1✔
381
    }
15✔
382

383
    Ok(true)
15✔
384
}
24✔
385

386
#[cfg(test)]
387
mod tests {
388
    use std::time::SystemTime;
389

390
    use uuid::{Timestamp, Uuid};
391

392
    use crate::{
393
        builder::tests::Builder,
394
        metadata::SupportedField,
395
        providers::{tests::TestCatalystSignedDocumentProvider, CatalystSignedDocumentProvider},
396
        validator::{document_rules_init, validate_id_and_ver},
397
        UuidV7,
398
    };
399

400
    #[test]
401
    fn document_id_and_ver_test() {
1✔
402
        let provider = TestCatalystSignedDocumentProvider::default();
1✔
403
        let now = SystemTime::now()
1✔
404
            .duration_since(SystemTime::UNIX_EPOCH)
1✔
405
            .unwrap()
1✔
406
            .as_secs();
1✔
407

408
        let uuid_v7 = UuidV7::new();
1✔
409
        let doc = Builder::new()
1✔
410
            .with_metadata_field(SupportedField::Id(uuid_v7))
1✔
411
            .with_metadata_field(SupportedField::Ver(uuid_v7))
1✔
412
            .build();
1✔
413

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

417
        let ver = Uuid::new_v7(Timestamp::from_unix_time(now - 1, 0, 0, 0))
1✔
418
            .try_into()
1✔
419
            .unwrap();
1✔
420
        let id = Uuid::new_v7(Timestamp::from_unix_time(now + 1, 0, 0, 0))
1✔
421
            .try_into()
1✔
422
            .unwrap();
1✔
423
        assert!(ver < id);
1✔
424
        let doc = Builder::new()
1✔
425
            .with_metadata_field(SupportedField::Id(id))
1✔
426
            .with_metadata_field(SupportedField::Ver(ver))
1✔
427
            .build();
1✔
428

429
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
430
        assert!(!is_valid);
1✔
431

432
        let to_far_in_past = Uuid::new_v7(Timestamp::from_unix_time(
1✔
433
            now - provider.past_threshold().unwrap().as_secs() - 1,
1✔
434
            0,
435
            0,
436
            0,
437
        ))
438
        .try_into()
1✔
439
        .unwrap();
1✔
440
        let doc = Builder::new()
1✔
441
            .with_metadata_field(SupportedField::Id(to_far_in_past))
1✔
442
            .with_metadata_field(SupportedField::Ver(to_far_in_past))
1✔
443
            .build();
1✔
444

445
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
446
        assert!(!is_valid);
1✔
447

448
        let to_far_in_future = Uuid::new_v7(Timestamp::from_unix_time(
1✔
449
            now + provider.future_threshold().unwrap().as_secs() + 1,
1✔
450
            0,
451
            0,
452
            0,
453
        ))
454
        .try_into()
1✔
455
        .unwrap();
1✔
456
        let doc = Builder::new()
1✔
457
            .with_metadata_field(SupportedField::Id(to_far_in_future))
1✔
458
            .with_metadata_field(SupportedField::Ver(to_far_in_future))
1✔
459
            .build();
1✔
460

461
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
462
        assert!(!is_valid);
1✔
463
    }
1✔
464

465
    #[test]
466
    fn document_rules_init_test() {
1✔
467
        document_rules_init();
1✔
468
    }
1✔
469
}
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

© 2025 Coveralls, Inc