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

input-output-hk / catalyst-libs / 18964947259

31 Oct 2025 06:22AM UTC coverage: 64.13%. First build
18964947259

Pull #610

github

web-flow
Merge 708da684e into 974e33a2c
Pull Request #610: fix(rust/signed-doc): Replacing `SystemTime::now` with `Utc::now`

19 of 21 new or added lines in 1 file covered. (90.48%)

10947 of 17070 relevant lines covered (64.13%)

3433.83 hits per line

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

82.2
/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::{collections::HashMap, fmt, ops::Neg, sync::LazyLock};
7

8
use anyhow::Context;
9
use catalyst_types::{
10
    catalyst_id::{role_index::RoleId, CatalystId},
11
    problem_report::ProblemReport,
12
    uuid::{Uuid, UuidV4},
13
};
14
use chrono::{DateTime, Utc};
15
use coset::{CoseSign, CoseSignature};
16
use rules::{
17
    ContentEncodingRule, ContentRule, ContentSchema, ContentTypeRule, ParametersRule, RefRule,
18
    ReplyRule, Rules, SectionRule, SignatureKidRule,
19
};
20

21
use crate::{
22
    doc_types::{
23
        CATEGORY_DOCUMENT_UUID_TYPE, COMMENT_DOCUMENT_UUID_TYPE, COMMENT_TEMPLATE_UUID_TYPE,
24
        PROPOSAL_ACTION_DOCUMENT_UUID_TYPE, PROPOSAL_DOCUMENT_UUID_TYPE,
25
        PROPOSAL_TEMPLATE_UUID_TYPE,
26
    },
27
    providers::{CatalystSignedDocumentProvider, VerifyingKeyProvider},
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<Uuid, Rules>> = LazyLock::new(document_rules_init);
33

34
/// Returns an [`UuidV4`] from the provided argument, panicking if the argument is
35
/// invalid.
36
#[allow(clippy::expect_used)]
37
fn expect_uuidv4<T>(t: T) -> UuidV4
8✔
38
where T: TryInto<UuidV4, Error: fmt::Debug> {
8✔
39
    t.try_into().expect("Must be a valid UUID V4")
8✔
40
}
8✔
41

42
/// `DOCUMENT_RULES` initialization function
43
#[allow(clippy::expect_used)]
44
fn document_rules_init() -> HashMap<Uuid, Rules> {
1✔
45
    let mut document_rules_map = HashMap::new();
1✔
46

47
    let proposal_document_rules = Rules {
1✔
48
        content_type: ContentTypeRule {
1✔
49
            exp: ContentType::Json,
1✔
50
        },
1✔
51
        content_encoding: ContentEncodingRule {
1✔
52
            exp: ContentEncoding::Brotli,
1✔
53
            optional: false,
1✔
54
        },
1✔
55
        content: ContentRule::Templated {
1✔
56
            exp_template_type: expect_uuidv4(PROPOSAL_TEMPLATE_UUID_TYPE),
1✔
57
        },
1✔
58
        parameters: ParametersRule::Specified {
1✔
59
            exp_parameters_type: expect_uuidv4(CATEGORY_DOCUMENT_UUID_TYPE),
1✔
60
            optional: false,
1✔
61
        },
1✔
62
        doc_ref: RefRule::NotSpecified,
1✔
63
        reply: ReplyRule::NotSpecified,
1✔
64
        section: SectionRule::NotSpecified,
1✔
65
        kid: SignatureKidRule {
1✔
66
            exp: &[RoleId::Proposer],
1✔
67
        },
1✔
68
    };
1✔
69

70
    document_rules_map.insert(PROPOSAL_DOCUMENT_UUID_TYPE, proposal_document_rules);
1✔
71

72
    let comment_document_rules = Rules {
1✔
73
        content_type: ContentTypeRule {
1✔
74
            exp: ContentType::Json,
1✔
75
        },
1✔
76
        content_encoding: ContentEncodingRule {
1✔
77
            exp: ContentEncoding::Brotli,
1✔
78
            optional: false,
1✔
79
        },
1✔
80
        content: ContentRule::Templated {
1✔
81
            exp_template_type: expect_uuidv4(COMMENT_TEMPLATE_UUID_TYPE),
1✔
82
        },
1✔
83
        doc_ref: RefRule::Specified {
1✔
84
            exp_ref_type: expect_uuidv4(PROPOSAL_DOCUMENT_UUID_TYPE),
1✔
85
            optional: false,
1✔
86
        },
1✔
87
        reply: ReplyRule::Specified {
1✔
88
            exp_reply_type: expect_uuidv4(COMMENT_DOCUMENT_UUID_TYPE),
1✔
89
            optional: true,
1✔
90
        },
1✔
91
        parameters: ParametersRule::Specified {
1✔
92
            exp_parameters_type: expect_uuidv4(CATEGORY_DOCUMENT_UUID_TYPE),
1✔
93
            optional: false,
1✔
94
        },
1✔
95
        section: SectionRule::Specified { optional: true },
1✔
96
        kid: SignatureKidRule {
1✔
97
            exp: &[RoleId::Role0],
1✔
98
        },
1✔
99
    };
1✔
100
    document_rules_map.insert(COMMENT_DOCUMENT_UUID_TYPE, comment_document_rules);
1✔
101

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

135
    document_rules_map.insert(
1✔
136
        PROPOSAL_ACTION_DOCUMENT_UUID_TYPE,
137
        proposal_submission_action_rules,
1✔
138
    );
139

140
    document_rules_map
1✔
141
}
1✔
142

143
/// A comprehensive document type based validation of the `CatalystSignedDocument`.
144
/// Includes time based validation of the `id` and `ver` fields based on the provided
145
/// `future_threshold` and `past_threshold` threshold values (in seconds).
146
/// Return true if it is valid, otherwise return false.
147
///
148
/// # Errors
149
/// If `provider` returns error, fails fast throwing that error.
150
pub async fn validate<Provider>(
×
151
    doc: &CatalystSignedDocument,
×
152
    provider: &Provider,
×
153
) -> anyhow::Result<bool>
×
154
where
×
155
    Provider: CatalystSignedDocumentProvider,
×
156
{
×
157
    let Ok(doc_type) = doc.doc_type() else {
×
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)? {
×
166
        return Ok(false);
×
167
    }
×
168

169
    let Some(rules) = DOCUMENT_RULES.get(&doc_type.uuid()) else {
×
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
×
179
}
×
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>(
5✔
189
    doc: &CatalystSignedDocument,
5✔
190
    provider: &Provider,
5✔
191
) -> anyhow::Result<bool>
5✔
192
where
5✔
193
    Provider: CatalystSignedDocumentProvider,
5✔
194
{
195
    let id = doc.doc_id().ok();
5✔
196
    let ver = doc.doc_ver().ok();
5✔
197
    if id.is_none() {
5✔
198
        doc.report().missing_field(
×
199
            "id",
×
200
            "Can't get a document id during the validation process",
×
201
        );
×
202
    }
5✔
203
    if ver.is_none() {
5✔
204
        doc.report().missing_field(
×
205
            "ver",
×
206
            "Can't get a document ver during the validation process",
×
207
        );
×
208
    }
5✔
209
    match (id, ver) {
5✔
210
        (Some(id), Some(ver)) => {
5✔
211
            let mut is_valid = true;
5✔
212
            if ver < id {
5✔
213
                doc.report().invalid_value(
1✔
214
                    "ver",
1✔
215
                    &ver.to_string(),
1✔
216
                    "ver < id",
1✔
217
                    &format!("Document Version {ver} cannot be smaller than Document ID {id}"),
1✔
218
                );
1✔
219
                is_valid = false;
1✔
220
            }
4✔
221

222
            let (ver_time_secs, ver_time_nanos) = ver
5✔
223
                .uuid()
5✔
224
                .get_timestamp()
5✔
225
                .ok_or(anyhow::anyhow!("Document ver field must be a UUIDv7"))?
5✔
226
                .to_unix();
5✔
227

228
            let Some(ver_time) = i64::try_from(ver_time_secs)
5✔
229
                .ok()
5✔
230
                .and_then(|ver_time_secs| DateTime::from_timestamp(ver_time_secs, ver_time_nanos))
5✔
231
            else {
232
                doc.report().invalid_value(
×
233
                    "ver",
×
234
                    &ver.to_string(),
×
NEW
235
                    "Must a valid UTC date time since `UNIX_EPOCH`",
×
NEW
236
                    "Cannot instantiate a valid `DateTime<Utc>` value from the provided `ver` field timestamp.",
×
237
                );
238
                return Ok(false);
×
239
            };
240

241
            let now = Utc::now();
5✔
242
            let time_delta = ver_time.signed_duration_since(now);
5✔
243

244
            if let Ok(version_age) = time_delta.to_std() {
5✔
245
                // `now` is earlier than `ver_time`
246
                if let Some(future_threshold) = provider.future_threshold() {
1✔
247
                    if version_age > future_threshold {
1✔
248
                        doc.report().invalid_value(
1✔
249
                        "ver",
1✔
250
                        &ver.to_string(),
1✔
251
                        "ver < now + future_threshold",
1✔
252
                        &format!("Document Version timestamp {id} cannot be too far in future (threshold: {future_threshold:?}) from now: {now}"),
1✔
253
                    );
1✔
254
                        is_valid = false;
1✔
255
                    }
1✔
256
                }
×
257
            } else {
258
                let version_age = time_delta.neg().to_std().context("BUG! Cannot fail, this condition branch already means that 'time_delta' is negative, so negating it makes it positive.")?;
4✔
259

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

273
            Ok(is_valid)
5✔
274
        },
275

276
        _ => Ok(false),
×
277
    }
278
}
5✔
279

280
/// Verify document signatures.
281
/// Return true if all signatures are valid, otherwise return false.
282
///
283
/// # Errors
284
/// If `provider` returns error, fails fast throwing that error.
285
pub async fn validate_signatures(
10✔
286
    doc: &CatalystSignedDocument,
10✔
287
    provider: &impl VerifyingKeyProvider,
10✔
288
) -> anyhow::Result<bool> {
10✔
289
    let Ok(cose_sign) = doc.as_cose_sign() else {
10✔
290
        doc.report().other(
×
291
            "Cannot build a COSE sign object",
×
292
            "During encoding signed document as COSE SIGN",
×
293
        );
294
        return Ok(false);
×
295
    };
296

297
    if doc.signatures().is_empty() {
10✔
298
        doc.report().other(
1✔
299
            "Catalyst Signed Document is unsigned",
1✔
300
            "During Catalyst Signed Document signature validation",
1✔
301
        );
302
        return Ok(false);
1✔
303
    }
9✔
304

305
    let sign_rules = doc
9✔
306
        .signatures()
9✔
307
        .cose_signatures_with_kids()
9✔
308
        .map(|(signature, kid)| {
17✔
309
            validate_signature(&cose_sign, signature, kid, provider, doc.report())
17✔
310
        });
17✔
311

312
    let res = futures::future::join_all(sign_rules)
9✔
313
        .await
9✔
314
        .into_iter()
9✔
315
        .collect::<anyhow::Result<Vec<_>>>()?
9✔
316
        .iter()
9✔
317
        .all(|res| *res);
9✔
318

319
    Ok(res)
9✔
320
}
10✔
321

322
/// A single signature validation function
323
async fn validate_signature<Provider>(
17✔
324
    cose_sign: &CoseSign,
17✔
325
    signature: &CoseSignature,
17✔
326
    kid: &CatalystId,
17✔
327
    provider: &Provider,
17✔
328
    report: &ProblemReport,
17✔
329
) -> anyhow::Result<bool>
17✔
330
where
17✔
331
    Provider: VerifyingKeyProvider,
17✔
332
{
17✔
333
    let Some(pk) = provider.try_get_key(kid).await? else {
17✔
334
        report.other(
8✔
335
            &format!("Missing public key for {kid}."),
8✔
336
            "During public key extraction",
8✔
337
        );
338
        return Ok(false);
8✔
339
    };
340

341
    let tbs_data = cose_sign.tbs_data(&[], signature);
9✔
342
    let Ok(signature_bytes) = signature.signature.as_slice().try_into() else {
9✔
343
        report.invalid_value(
×
344
            "cose signature",
×
345
            &format!("{}", signature.signature.len()),
×
346
            &format!("must be {}", ed25519_dalek::Signature::BYTE_SIZE),
×
347
            "During encoding cose signature to bytes",
×
348
        );
349
        return Ok(false);
×
350
    };
351

352
    let signature = ed25519_dalek::Signature::from_bytes(signature_bytes);
9✔
353
    if pk.verify_strict(&tbs_data, &signature).is_err() {
9✔
354
        report.functional_validation(
×
355
            &format!("Verification failed for signature with Key ID {kid}"),
×
356
            "During signature validation with verifying key",
×
357
        );
358
        return Ok(false);
×
359
    }
9✔
360

361
    Ok(true)
9✔
362
}
17✔
363

364
#[cfg(test)]
365
mod tests {
366
    use chrono::Utc;
367
    use uuid::{Timestamp, Uuid};
368

369
    use crate::{
370
        providers::{tests::TestCatalystSignedDocumentProvider, CatalystSignedDocumentProvider},
371
        validator::{document_rules_init, validate_id_and_ver},
372
        Builder, UuidV7,
373
    };
374

375
    #[test]
376
    fn document_id_and_ver_test() {
1✔
377
        let provider = TestCatalystSignedDocumentProvider::default();
1✔
378

379
        let now: u64 = Utc::now().timestamp().try_into().unwrap();
1✔
380

381
        let uuid_v7 = UuidV7::new();
1✔
382
        let doc = Builder::new()
1✔
383
            .with_json_metadata(serde_json::json!({
1✔
384
                "id": uuid_v7.to_string(),
1✔
385
                "ver": uuid_v7.to_string()
1✔
386
            }))
387
            .unwrap()
1✔
388
            .build();
1✔
389

390
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
391
        assert!(is_valid);
1✔
392

393
        let uuid_v7 = Uuid::new_v7(Timestamp::from_unix_time(now, 0, 0, 0));
1✔
394
        let doc = Builder::new()
1✔
395
            .with_json_metadata(serde_json::json!({
1✔
396
                "id": uuid_v7.to_string(),
1✔
397
                "ver": uuid_v7.to_string()
1✔
398
            }))
399
            .unwrap()
1✔
400
            .build();
1✔
401

402
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
403
        assert!(is_valid);
1✔
404

405
        let ver = Uuid::new_v7(Timestamp::from_unix_time(now - 1, 0, 0, 0));
1✔
406
        let id = Uuid::new_v7(Timestamp::from_unix_time(now + 1, 0, 0, 0));
1✔
407
        assert!(ver < id);
1✔
408
        let doc = Builder::new()
1✔
409
            .with_json_metadata(serde_json::json!({
1✔
410
                "id": id.to_string(),
1✔
411
                "ver": ver.to_string()
1✔
412
            }))
413
            .unwrap()
1✔
414
            .build();
1✔
415

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

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

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

436
        let to_far_in_future = Uuid::new_v7(Timestamp::from_unix_time(
1✔
437
            now + provider.future_threshold().unwrap().as_secs() + 1,
1✔
438
            0,
439
            0,
440
            0,
441
        ));
442
        let doc = Builder::new()
1✔
443
            .with_json_metadata(serde_json::json!({
1✔
444
                "id": to_far_in_future.to_string(),
1✔
445
                "ver": to_far_in_future.to_string()
1✔
446
            }))
447
            .unwrap()
1✔
448
            .build();
1✔
449

450
        let is_valid = validate_id_and_ver(&doc, &provider).unwrap();
1✔
451
        assert!(!is_valid);
1✔
452
    }
1✔
453

454
    #[test]
455
    fn document_rules_init_test() {
1✔
456
        document_rules_init();
1✔
457
    }
1✔
458
}
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