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

input-output-hk / catalyst-libs / 14240194695

03 Apr 2025 09:57AM UTC coverage: 65.377% (+0.2%) from 65.175%
14240194695

push

github

web-flow
feat(rust/signed-doc): Catalyst Signed Document `id` and  `ver` fields timestamp validation (#265)

* add id and ver timebased validation

* fix tests

* add test

* fix spelling

* fix clippy

* Update rust/signed_doc/src/validator/mod.rs

Co-authored-by: Apisit Ritruengroj <38898766+apskhem@users.noreply.github.com>

* wip

* wip

---------

Co-authored-by: Apisit Ritruengroj <38898766+apskhem@users.noreply.github.com>

122 of 135 new or added lines in 1 file covered. (90.37%)

6 existing lines in 2 files now uncovered.

10542 of 16125 relevant lines covered (65.38%)

2790.68 hits per line

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

85.71
/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, sync::LazyLock, time::SystemTime};
7

8
use catalyst_types::{id_uri::IdUri, problem_report::ProblemReport, uuid::Uuid};
9
use coset::{CoseSign, CoseSignature};
10
use rules::{
11
    CategoryRule, ContentEncodingRule, ContentTypeRule, RefRule, ReplyRule, Rules, SectionRule,
12
    TemplateRule,
13
};
14

15
use crate::{
16
    doc_types::{
17
        COMMENT_DOCUMENT_UUID_TYPE, COMMENT_TEMPLATE_UUID_TYPE, PROPOSAL_ACTION_DOCUMENT_UUID_TYPE,
18
        PROPOSAL_DOCUMENT_UUID_TYPE, PROPOSAL_TEMPLATE_UUID_TYPE,
19
    },
20
    providers::{CatalystSignedDocumentProvider, VerifyingKeyProvider},
21
    CatalystSignedDocument, ContentEncoding, ContentType,
22
};
23

24
/// A table representing a full set or validation rules per document id.
25
static DOCUMENT_RULES: LazyLock<HashMap<Uuid, Rules>> = LazyLock::new(document_rules_init);
26

27
/// `DOCUMENT_RULES` initialization function
28
#[allow(clippy::expect_used)]
29
fn document_rules_init() -> HashMap<Uuid, Rules> {
10✔
30
    let mut document_rules_map = HashMap::new();
10✔
31

10✔
32
    let proposal_document_rules = Rules {
10✔
33
        content_type: ContentTypeRule {
10✔
34
            exp: ContentType::Json,
10✔
35
        },
10✔
36
        content_encoding: ContentEncodingRule {
10✔
37
            exp: ContentEncoding::Brotli,
10✔
38
            optional: false,
10✔
39
        },
10✔
40
        template: TemplateRule::Specified {
10✔
41
            exp_template_type: PROPOSAL_TEMPLATE_UUID_TYPE
10✔
42
                .try_into()
10✔
43
                .expect("Must be a valid UUID V4"),
10✔
44
        },
10✔
45
        category: CategoryRule::Specified { optional: true },
10✔
46
        doc_ref: RefRule::NotSpecified,
10✔
47
        reply: ReplyRule::NotSpecified,
10✔
48
        section: SectionRule::NotSpecified,
10✔
49
    };
10✔
50
    document_rules_map.insert(PROPOSAL_DOCUMENT_UUID_TYPE, proposal_document_rules);
10✔
51

10✔
52
    let comment_document_rules = Rules {
10✔
53
        content_type: ContentTypeRule {
10✔
54
            exp: ContentType::Json,
10✔
55
        },
10✔
56
        content_encoding: ContentEncodingRule {
10✔
57
            exp: ContentEncoding::Brotli,
10✔
58
            optional: false,
10✔
59
        },
10✔
60
        template: TemplateRule::Specified {
10✔
61
            exp_template_type: COMMENT_TEMPLATE_UUID_TYPE
10✔
62
                .try_into()
10✔
63
                .expect("Must be a valid UUID V4"),
10✔
64
        },
10✔
65
        doc_ref: RefRule::Specified {
10✔
66
            exp_ref_type: PROPOSAL_DOCUMENT_UUID_TYPE
10✔
67
                .try_into()
10✔
68
                .expect("Must be a valid UUID V4"),
10✔
69
            optional: false,
10✔
70
        },
10✔
71
        reply: ReplyRule::Specified {
10✔
72
            exp_reply_type: COMMENT_DOCUMENT_UUID_TYPE
10✔
73
                .try_into()
10✔
74
                .expect("Must be a valid UUID V4"),
10✔
75
            optional: true,
10✔
76
        },
10✔
77
        section: SectionRule::Specified { optional: true },
10✔
78
        category: CategoryRule::NotSpecified,
10✔
79
    };
10✔
80
    document_rules_map.insert(COMMENT_DOCUMENT_UUID_TYPE, comment_document_rules);
10✔
81

10✔
82
    let proposal_submission_action_rules = Rules {
10✔
83
        content_type: ContentTypeRule {
10✔
84
            exp: ContentType::Json,
10✔
85
        },
10✔
86
        content_encoding: ContentEncodingRule {
10✔
87
            exp: ContentEncoding::Brotli,
10✔
88
            optional: false,
10✔
89
        },
10✔
90
        template: TemplateRule::NotSpecified,
10✔
91
        category: CategoryRule::Specified { optional: true },
10✔
92
        doc_ref: RefRule::Specified {
10✔
93
            exp_ref_type: PROPOSAL_DOCUMENT_UUID_TYPE
10✔
94
                .try_into()
10✔
95
                .expect("Must be a valid UUID V4"),
10✔
96
            optional: false,
10✔
97
        },
10✔
98
        reply: ReplyRule::NotSpecified,
10✔
99
        section: SectionRule::NotSpecified,
10✔
100
    };
10✔
101

10✔
102
    document_rules_map.insert(
10✔
103
        PROPOSAL_ACTION_DOCUMENT_UUID_TYPE,
10✔
104
        proposal_submission_action_rules,
10✔
105
    );
10✔
106

10✔
107
    document_rules_map
10✔
108
}
10✔
109

110
/// A comprehensive document type based validation of the `CatalystSignedDocument`.
111
/// Includes time based validation of the `id` and `ver` fields based on the provided
112
/// `future_threshold` and `past_threshold` threshold values (in seconds).
113
/// Return true if it is valid, otherwise return false.
114
///
115
/// # Errors
116
/// If `provider` returns error, fails fast throwing that error.
117
pub async fn validate<Provider>(
9✔
118
    doc: &CatalystSignedDocument, future_threshold: u64, past_threshold: u64, provider: &Provider,
9✔
119
) -> anyhow::Result<bool>
9✔
120
where Provider: CatalystSignedDocumentProvider {
9✔
121
    let Ok(doc_type) = doc.doc_type() else {
9✔
122
        doc.report().missing_field(
×
123
            "type",
×
124
            "Can't get a document type during the validation process",
×
125
        );
×
126
        return Ok(false);
×
127
    };
128

129
    if !validate_id_and_ver(doc, future_threshold, past_threshold)? {
9✔
NEW
130
        return Ok(false);
×
131
    }
9✔
132

133
    let Some(rules) = DOCUMENT_RULES.get(&doc_type.uuid()) else {
9✔
134
        doc.report().invalid_value(
×
135
            "`type`",
×
136
            &doc.doc_type()?.to_string(),
×
137
            "Must be a known document type value",
×
138
            "Unsupported document type",
×
139
        );
×
140
        return Ok(false);
×
141
    };
142
    rules.check(doc, provider).await
9✔
143
}
9✔
144

145
/// Validates document `id` and `ver` fields on the timestamps:
146
/// 1. document `ver` cannot be smaller than document id field
147
/// 2. document `id` cannot be too far in the future (`future_threshold` arg) from
148
///    `SystemTime::now()` based on the provide threshold
149
/// 3. document `id` cannot be too far behind (`past_threshold` arg) from
150
///    `SystemTime::now()` based on the provide threshold
151
fn validate_id_and_ver(
13✔
152
    doc: &CatalystSignedDocument, future_threshold: u64, past_threshold: u64,
13✔
153
) -> anyhow::Result<bool> {
13✔
154
    let id = doc.doc_id().ok();
13✔
155
    let ver = doc.doc_ver().ok();
13✔
156
    if id.is_none() {
13✔
NEW
157
        doc.report().missing_field(
×
NEW
158
            "id",
×
NEW
159
            "Can't get a document id during the validation process",
×
NEW
160
        );
×
161
    }
13✔
162
    if ver.is_none() {
13✔
NEW
163
        doc.report().missing_field(
×
NEW
164
            "ver",
×
NEW
165
            "Can't get a document ver during the validation process",
×
NEW
166
        );
×
167
    }
13✔
168
    match (id, ver) {
13✔
169
        (Some(id), Some(ver)) => {
13✔
170
            let mut is_valid = true;
13✔
171
            if ver < id {
13✔
172
                doc.report().invalid_value(
1✔
173
                    "ver",
1✔
174
                    &ver.to_string(),
1✔
175
                    "ver < id",
1✔
176
                    &format!("Document Version {ver} cannot be smaller than Document ID {id}"),
1✔
177
                );
1✔
178
                is_valid = false;
1✔
179
            }
12✔
180

181
            let (id_time, _) = id
13✔
182
                .uuid()
13✔
183
                .get_timestamp()
13✔
184
                .ok_or(anyhow::anyhow!("Document id field must be a UUIDv7"))?
13✔
185
                .to_unix();
13✔
186

187
            let now = SystemTime::now()
13✔
188
                .duration_since(SystemTime::UNIX_EPOCH)
13✔
189
                .map_err(|_| {
13✔
NEW
190
                    anyhow::anyhow!(
×
NEW
191
                        "Cannot validate document id field, SystemTime before UNIX EPOCH!"
×
NEW
192
                    )
×
193
                })?
13✔
194
                .as_secs();
13✔
195

13✔
196
            if id_time > now.saturating_add(future_threshold) {
13✔
197
                doc.report().invalid_value(
1✔
198
                    "id",
1✔
199
                    &ver.to_string(),
1✔
200
                    "id < now + future_threshold",
1✔
201
                    &format!("Document ID timestamp {id} cannot be too far in future (threshold: {future_threshold}) from now: {now}"),
1✔
202
                );
1✔
203
                is_valid = false;
1✔
204
            }
12✔
205
            if id_time < now.saturating_sub(past_threshold) {
13✔
206
                doc.report().invalid_value(
1✔
207
                    "id",
1✔
208
                    &ver.to_string(),
1✔
209
                    "id > now - past_threshold",
1✔
210
                    &format!("Document ID timestamp {id} cannot be too far behind (threshold: {past_threshold}) from now: {now}"),
1✔
211
                );
1✔
212
                is_valid = false;
1✔
213
            }
12✔
214
            Ok(is_valid)
13✔
215
        },
216

NEW
217
        _ => Ok(false),
×
218
    }
219
}
13✔
220

221
/// Verify document signatures.
222
/// Return true if all signatures are valid, otherwise return false.
223
///
224
/// # Errors
225
/// If `provider` returns error, fails fast throwing that error.
226
pub async fn validate_signatures(
6✔
227
    doc: &CatalystSignedDocument, provider: &impl VerifyingKeyProvider,
6✔
228
) -> anyhow::Result<bool> {
6✔
229
    let Ok(cose_sign) = doc.as_cose_sign() else {
6✔
230
        doc.report().other(
×
231
            "Cannot build a COSE sign object",
×
232
            "During encoding signed document as COSE SIGN",
×
233
        );
×
234
        return Ok(false);
×
235
    };
236

237
    let sign_rules = doc
6✔
238
        .signatures()
6✔
239
        .cose_signatures_with_kids()
6✔
240
        .map(|(signature, kid)| {
14✔
241
            validate_signature(&cose_sign, signature, kid, provider, doc.report())
14✔
242
        });
14✔
243

244
    let res = futures::future::join_all(sign_rules)
6✔
245
        .await
6✔
246
        .into_iter()
6✔
247
        .collect::<anyhow::Result<Vec<_>>>()?
6✔
248
        .iter()
6✔
249
        .all(|res| *res);
10✔
250

6✔
251
    Ok(res)
6✔
252
}
6✔
253

254
/// A single signature validation function
255
async fn validate_signature<Provider>(
14✔
256
    cose_sign: &CoseSign, signature: &CoseSignature, kid: &IdUri, provider: &Provider,
14✔
257
    report: &ProblemReport,
14✔
258
) -> anyhow::Result<bool>
14✔
259
where
14✔
260
    Provider: VerifyingKeyProvider,
14✔
261
{
14✔
262
    let Some(pk) = provider.try_get_key(kid).await? else {
14✔
263
        report.other(
8✔
264
            &format!("Missing public key for {kid}."),
8✔
265
            "During public key extraction",
8✔
266
        );
8✔
267
        return Ok(false);
8✔
268
    };
269

270
    let tbs_data = cose_sign.tbs_data(&[], signature);
6✔
271
    let Ok(signature_bytes) = signature.signature.as_slice().try_into() else {
6✔
272
        report.invalid_value(
×
273
            "cose signature",
×
274
            &format!("{}", signature.signature.len()),
×
275
            &format!("must be {}", ed25519_dalek::Signature::BYTE_SIZE),
×
276
            "During encoding cose signature to bytes",
×
277
        );
×
278
        return Ok(false);
×
279
    };
280

281
    let signature = ed25519_dalek::Signature::from_bytes(signature_bytes);
6✔
282
    if pk.verify_strict(&tbs_data, &signature).is_err() {
6✔
283
        report.functional_validation(
×
284
            &format!("Verification failed for signature with Key ID {kid}"),
×
285
            "During signature validation with verifying key",
×
286
        );
×
287
        return Ok(false);
×
288
    }
6✔
289

6✔
290
    Ok(true)
6✔
291
}
14✔
292

293
#[allow(missing_docs)]
294
pub mod tests {
295
    /// A Test Future Threshold value for the Document's time based id field validation (5
296
    /// secs);
297
    pub const TEST_FUTURE_THRESHOLD: u64 = 5;
298
    /// A Test Future Threshold value for the Document's time based id field validation (5
299
    /// secs);
300
    pub const TEST_PAST_THRESHOLD: u64 = 5;
301

302
    #[cfg(test)]
303
    #[test]
304
    fn document_id_and_ver_test() {
1✔
305
        use std::time::SystemTime;
306

307
        use uuid::{Timestamp, Uuid};
308

309
        use crate::{validator::validate_id_and_ver, Builder, UuidV7};
310

311
        let now = SystemTime::now()
1✔
312
            .duration_since(SystemTime::UNIX_EPOCH)
1✔
313
            .unwrap()
1✔
314
            .as_secs();
1✔
315

1✔
316
        let uuid_v7 = UuidV7::new();
1✔
317
        let doc = Builder::new()
1✔
318
            .with_json_metadata(serde_json::json!({
1✔
319
                "id": uuid_v7.to_string(),
1✔
320
                "ver": uuid_v7.to_string()
1✔
321
            }))
1✔
322
            .unwrap()
1✔
323
            .build();
1✔
324

1✔
325
        let is_valid =
1✔
326
            validate_id_and_ver(&doc, TEST_FUTURE_THRESHOLD, TEST_PAST_THRESHOLD).unwrap();
1✔
327
        assert!(is_valid);
1✔
328

329
        let ver = Uuid::new_v7(Timestamp::from_unix_time(now - 1, 0, 0, 0));
1✔
330
        let id = Uuid::new_v7(Timestamp::from_unix_time(now + 1, 0, 0, 0));
1✔
331
        assert!(ver < id);
1✔
332
        let doc = Builder::new()
1✔
333
            .with_json_metadata(serde_json::json!({
1✔
334
                "id": id.to_string(),
1✔
335
                "ver": ver.to_string()
1✔
336
            }))
1✔
337
            .unwrap()
1✔
338
            .build();
1✔
339

1✔
340
        let is_valid =
1✔
341
            validate_id_and_ver(&doc, TEST_FUTURE_THRESHOLD, TEST_PAST_THRESHOLD).unwrap();
1✔
342
        assert!(!is_valid);
1✔
343

344
        let to_far_in_past = Uuid::new_v7(Timestamp::from_unix_time(
1✔
345
            now - TEST_PAST_THRESHOLD - 1,
1✔
346
            0,
1✔
347
            0,
1✔
348
            0,
1✔
349
        ));
1✔
350
        let doc = Builder::new()
1✔
351
            .with_json_metadata(serde_json::json!({
1✔
352
                "id": to_far_in_past.to_string(),
1✔
353
                "ver": to_far_in_past.to_string()
1✔
354
            }))
1✔
355
            .unwrap()
1✔
356
            .build();
1✔
357

1✔
358
        let is_valid =
1✔
359
            validate_id_and_ver(&doc, TEST_FUTURE_THRESHOLD, TEST_PAST_THRESHOLD).unwrap();
1✔
360
        assert!(!is_valid);
1✔
361

362
        let to_far_in_future = Uuid::new_v7(Timestamp::from_unix_time(
1✔
363
            now + TEST_FUTURE_THRESHOLD + 1,
1✔
364
            0,
1✔
365
            0,
1✔
366
            0,
1✔
367
        ));
1✔
368
        let doc = Builder::new()
1✔
369
            .with_json_metadata(serde_json::json!({
1✔
370
                "id": to_far_in_future.to_string(),
1✔
371
                "ver": to_far_in_future.to_string()
1✔
372
            }))
1✔
373
            .unwrap()
1✔
374
            .build();
1✔
375

1✔
376
        let is_valid =
1✔
377
            validate_id_and_ver(&doc, TEST_FUTURE_THRESHOLD, TEST_PAST_THRESHOLD).unwrap();
1✔
378
        assert!(!is_valid);
1✔
379
    }
1✔
380

381
    #[cfg(test)]
382
    #[test]
383
    fn document_rules_init_test() {
1✔
384
        use super::document_rules_init;
385

386
        document_rules_init();
1✔
387
    }
1✔
388
}
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