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

input-output-hk / catalyst-libs / 16646523141

31 Jul 2025 10:28AM UTC coverage: 67.341% (+3.8%) from 63.544%
16646523141

push

github

web-flow
feat(rust/signed-doc): Implement new Catalyst Signed Doc (#338)

* chore: add new line to open pr

Signed-off-by: bkioshn <bkioshn@gmail.com>

* chore: revert

Signed-off-by: bkioshn <bkioshn@gmail.com>

* feat(rust/signed-doc): add new type `DocType` (#339)

* feat(signed-doc): add new type DocType

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): add conversion policy

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): doc type

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): doc type error

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): seperate test

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): format

Signed-off-by: bkioshn <bkioshn@gmail.com>

---------

Signed-off-by: bkioshn <bkioshn@gmail.com>

* feat(rust/signed-doc): Add initial decoding tests for the Catalyst Signed Documents (#349)

* wip

* wip

* fix fmt

* fix spelling

* fix clippy

* fix(rust/signed-doc): Apply new `DocType` (#347)

* feat(signed-doc): add new type DocType

Signed-off-by: bkioshn <bkioshn@gmail.com>

* wip: apply doctype

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): add more function to DocType

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): map old doctype to new doctype

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): add eq to uuidv4

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): fix validator

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): minor fixes

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(catalyst-types): add hash to uuidv4

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): decoding test

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): doctype

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(signed-doc): minor fixes

Signed-off-by: bkioshn <bkioshn@gmail.com>

* chore(sign-doc): fix comment

Signed-off-by: bkioshn <bkioshn@gmail.com>

* fix(catalyst-types): add froms... (continued)

2453 of 2675 new or added lines in 38 files covered. (91.7%)

19 existing lines in 7 files now uncovered.

11312 of 16798 relevant lines covered (67.34%)

2525.16 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 rules;
4
pub(crate) mod utils;
5

6
use std::{
7
    collections::HashMap,
8
    sync::{Arc, 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, LinkField,
16
    ParameterLinkRefRule, ParametersRule, RefRule, ReplyRule, Rules, SectionRule, SignatureKidRule,
17
};
18

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

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

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

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

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

126
    let proposal_action_json_schema = jsonschema::options()
15✔
127
        .with_draft(jsonschema::Draft::Draft7)
15✔
128
        .build(
15✔
129
            &serde_json::from_str(include_str!(
15✔
130
                "./../../../../specs/definitions/signed_docs/docs/payload_schemas/proposal_submission_action.schema.json"
15✔
131
            ))
15✔
132
            .expect("Must be a valid json file"),
15✔
133
        )
134
        .expect("Must be a valid json scheme file");
15✔
135
    Rules {
15✔
136
        content_type: ContentTypeRule {
15✔
137
            exp: ContentType::Json,
15✔
138
        },
15✔
139
        content_encoding: ContentEncodingRule {
15✔
140
            exp: ContentEncoding::Brotli,
15✔
141
            optional: false,
15✔
142
        },
15✔
143
        content: ContentRule::Static(ContentSchema::Json(proposal_action_json_schema)),
15✔
144
        parameters: ParametersRule::Specified {
15✔
145
            exp_parameters_type: parameters,
15✔
146
            optional: false,
15✔
147
        },
15✔
148
        doc_ref: RefRule::Specified {
15✔
149
            exp_ref_type: PROPOSAL.clone(),
15✔
150
            optional: false,
15✔
151
        },
15✔
152
        reply: ReplyRule::NotSpecified,
15✔
153
        section: SectionRule::NotSpecified,
15✔
154
        kid: SignatureKidRule {
15✔
155
            exp: &[RoleId::Proposer],
15✔
156
        },
15✔
157
        param_link_ref: ParameterLinkRefRule::Specified {
15✔
158
            field: LinkField::Ref,
15✔
159
        },
15✔
160
    }
15✔
161
}
15✔
162

163
/// `DOCUMENT_RULES` initialization function
164
fn document_rules_init() -> HashMap<DocType, Arc<Rules>> {
15✔
165
    let mut document_rules_map = HashMap::new();
15✔
166

167
    let proposal_rules = Arc::new(proposal_rule());
15✔
168
    let comment_rules = Arc::new(proposal_comment_rule());
15✔
169
    let action_rules = Arc::new(proposal_submission_action_rule());
15✔
170

171
    document_rules_map.insert(PROPOSAL.clone(), Arc::clone(&proposal_rules));
15✔
172
    document_rules_map.insert(PROPOSAL_COMMENT.clone(), Arc::clone(&comment_rules));
15✔
173
    document_rules_map.insert(
15✔
174
        PROPOSAL_SUBMISSION_ACTION.clone(),
15✔
175
        Arc::clone(&action_rules),
15✔
176
    );
177

178
    document_rules_map
15✔
179
}
15✔
180

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

200
    if !validate_id_and_ver(doc, provider)? {
14✔
201
        return Ok(false);
×
202
    }
14✔
203

204
    let Some(rules) = DOCUMENT_RULES.get(doc_type) else {
14✔
205
        doc.report().invalid_value(
×
206
            "`type`",
×
207
            &doc.doc_type()?.to_string(),
×
208
            "Must be a known document type value",
×
209
            "Unsupported document type",
×
210
        );
211
        return Ok(false);
×
212
    };
213
    rules.check(doc, provider).await
14✔
214
}
14✔
215

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

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

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

272
            let now = SystemTime::now();
18✔
273

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

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

306
            Ok(is_valid)
18✔
307
        },
308

309
        _ => Ok(false),
×
310
    }
311
}
18✔
312

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

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

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

341
    Ok(res)
15✔
342
}
16✔
343

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

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

359
    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✔
360

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

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

380
    Ok(true)
15✔
381
}
24✔
382

383
#[cfg(test)]
384
mod tests {
385
    use std::time::SystemTime;
386

387
    use uuid::{Timestamp, Uuid};
388

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

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

405
        let uuid_v7 = UuidV7::new();
1✔
406
        let doc = Builder::new()
1✔
407
            .with_metadata_field(SupportedField::Id(uuid_v7))
1✔
408
            .with_metadata_field(SupportedField::Ver(uuid_v7))
1✔
409
            .build();
1✔
410

411
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
412
        assert!(is_valid);
1✔
413

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

426
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
427
        assert!(!is_valid);
1✔
428

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

442
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
443
        assert!(!is_valid);
1✔
444

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

458
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
459
        assert!(!is_valid);
1✔
460
    }
1✔
461

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