ic_http_certification/hash/
response_hash.rs

1use super::Hash;
2use crate::{cel::DefaultResponseCertificationType, DefaultResponseCertification, HttpResponse};
3use ic_representation_independent_hash::{hash, representation_independent_hash, Value};
4
5/// The name of the IC-Certificate header.
6pub const CERTIFICATE_HEADER_NAME: &str = "IC-Certificate";
7
8/// The name of the IC-CertificateExpression header.
9pub const CERTIFICATE_EXPRESSION_HEADER_NAME: &str = "IC-CertificateExpression";
10
11const RESPONSE_STATUS_PSEUDO_HEADER_NAME: &str = ":ic-cert-status";
12
13/// Representation of response headers filtered by [filter_response_headers].
14#[derive(Debug)]
15pub struct ResponseHeaders {
16    /// Filtered headers
17    pub headers: Vec<(String, String)>,
18    /// IC-Certificate header
19    pub certificate: Option<String>,
20}
21
22/// Filters the headers of an [HttpResponse] according to a CEL expression defined by
23/// [DefaultResponseCertification].
24pub fn filter_response_headers(
25    response: &HttpResponse,
26    response_certification: &DefaultResponseCertification<'_>,
27) -> ResponseHeaders {
28    let headers_filter: Box<dyn Fn(_) -> _> = match response_certification.get_type() {
29        DefaultResponseCertificationType::CertifiedResponseHeaders(headers_to_include) => {
30            Box::new(move |header_name: &String| {
31                headers_to_include.iter().any(|header_to_include| {
32                    header_to_include.eq_ignore_ascii_case(&header_name.to_string())
33                })
34            })
35        }
36        DefaultResponseCertificationType::ResponseHeaderExclusions(headers_to_exclude) => {
37            Box::new(move |header_name: &String| {
38                !headers_to_exclude.iter().any(|header_to_exclude| {
39                    header_to_exclude.eq_ignore_ascii_case(&header_name.to_string())
40                })
41            })
42        }
43    };
44
45    let mut response_headers = ResponseHeaders {
46        headers: vec![],
47        certificate: None,
48    };
49
50    response_headers.headers = response
51        .headers()
52        .iter()
53        .filter_map(|(header_name, header_value)| {
54            let is_certificate_header = header_name
55                .to_string()
56                .eq_ignore_ascii_case(CERTIFICATE_HEADER_NAME);
57            if is_certificate_header {
58                response_headers.certificate = Some(header_value.into());
59                return None;
60            }
61
62            let is_certificate_expression_header = header_name
63                .to_string()
64                .eq_ignore_ascii_case(CERTIFICATE_EXPRESSION_HEADER_NAME);
65            if is_certificate_expression_header {
66                return Some((
67                    header_name.to_string().to_ascii_lowercase(),
68                    String::from(header_value),
69                ));
70            }
71
72            if headers_filter(header_name) {
73                return Some((
74                    header_name.to_string().to_ascii_lowercase(),
75                    String::from(header_value),
76                ));
77            }
78
79            None
80        })
81        .collect();
82
83    response_headers
84}
85
86/// Calculates the
87/// [Representation Independent Hash](https://internetcomputer.org/docs/current/references/ic-interface-spec/#hash-of-map)
88/// of [ResponseHeaders] that have been filtered with [filter_response_headers].
89pub fn response_headers_hash(status_code: &u64, response_headers: &ResponseHeaders) -> Hash {
90    let mut headers_to_verify: Vec<(String, Value)> = response_headers
91        .headers
92        .iter()
93        .map(|(header_name, header_value)| {
94            (
95                header_name.to_string(),
96                Value::String(String::from(header_value)),
97            )
98        })
99        .collect();
100
101    headers_to_verify.push((
102        RESPONSE_STATUS_PSEUDO_HEADER_NAME.into(),
103        Value::Number(*status_code),
104    ));
105
106    representation_independent_hash(&headers_to_verify)
107}
108
109/// Calculates the
110/// [Representation Independent Hash](https://internetcomputer.org/docs/current/references/ic-interface-spec/#hash-of-map)
111/// of an [HttpResponse] according to a CEL expression defined by [DefaultResponseCertification].
112///
113/// An optional response body hash may be provided if this is known beforehand. If this override is not
114/// provided then the response body hash will be calculated by this function.
115pub fn response_hash(
116    response: &HttpResponse,
117    response_certification: &DefaultResponseCertification,
118    response_body_hash: Option<Hash>,
119) -> Hash {
120    let response_body_hash = response_body_hash.unwrap_or_else(|| hash(response.body()));
121
122    let filtered_headers = filter_response_headers(response, response_certification);
123    let concatenated_hashes = [
124        response_headers_hash(&response.status_code().as_u16().into(), &filtered_headers),
125        response_body_hash,
126    ]
127    .concat();
128
129    hash(concatenated_hashes.as_slice())
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    const HELLO_WORLD_BODY: &[u8] = &[72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33];
137    const CERTIFICATE: &str = "certificate=:SGVsbG8gQ2VydGlmaWNhdGUh:,tree=:SGVsbG8gVHJlZSE=:";
138    const HEADER_EXCLUSIONS_CEL_EXPRESSION: &str = r#"
139        default_certification (
140          ValidationArgs {
141            certification: Certification {
142              no_request_certification: Empty {},
143              response_certification: ResponseCertification {
144                response_header_exclusions: ResponseHeaderList {
145                  headers: ["Content-Security-Policy"]
146                }
147              }
148            }
149          }
150        )
151    "#;
152    const CERTIFIED_HEADERS_CEL_EXPRESSION: &str = r#"
153        default_certification (
154          ValidationArgs {
155            certification: Certification {
156              no_request_certification: Empty {},
157              response_certification: ResponseCertification {
158                certified_response_headers: ResponseHeaderList {
159                  headers: ["Accept-Encoding", "Cache-Control"]
160                }
161              }
162            }
163          }
164        )
165    "#;
166
167    #[test]
168    fn response_with_certified_headers_without_excluded_headers() {
169        let response_certification =
170            DefaultResponseCertification::certified_response_headers(vec!["Accept-Encoding"]);
171        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
172        let response_headers = filter_response_headers(&response, &response_certification);
173
174        assert_eq!(
175            response_headers.headers,
176            vec![
177                (
178                    CERTIFICATE_EXPRESSION_HEADER_NAME.to_lowercase(),
179                    remove_whitespace(CERTIFIED_HEADERS_CEL_EXPRESSION),
180                ),
181                ("accept-encoding".into(), "gzip".into()),
182            ]
183        );
184    }
185
186    #[test]
187    fn response_with_certified_headers() {
188        let response_certification =
189            DefaultResponseCertification::certified_response_headers(vec![
190                "Accept-Encoding",
191                "Cache-Control",
192            ]);
193        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
194        let response_headers = filter_response_headers(&response, &response_certification);
195
196        assert_eq!(
197            response_headers.headers,
198            vec![
199                (
200                    CERTIFICATE_EXPRESSION_HEADER_NAME.to_lowercase(),
201                    remove_whitespace(CERTIFIED_HEADERS_CEL_EXPRESSION),
202                ),
203                ("accept-encoding".into(), "gzip".into()),
204                ("cache-control".into(), "no-cache".into()),
205                ("cache-control".into(), "no-store".into()),
206            ]
207        );
208    }
209
210    #[test]
211    fn response_hash_with_certified_headers() {
212        let response_certification =
213            DefaultResponseCertification::certified_response_headers(vec![
214                "Accept-Encoding",
215                "Cache-Control",
216            ]);
217        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
218        let expected_hash =
219            hex::decode("3393250e3cedc30408dcb7e8963898c3d7549b8a0b76496b82fdfeae99c2ac78")
220                .unwrap();
221
222        let result = response_hash(&response, &response_certification, None);
223
224        assert_eq!(result, expected_hash.as_slice());
225    }
226
227    #[test]
228    fn response_hash_with_certified_headers_without_excluded_headers() {
229        let response_certification =
230            DefaultResponseCertification::certified_response_headers(vec!["Accept-Encoding"]);
231        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
232        let response_without_excluded_headers = HttpResponse::ok(
233            HELLO_WORLD_BODY,
234            vec![
235                (
236                    CERTIFICATE_EXPRESSION_HEADER_NAME.into(),
237                    remove_whitespace(CERTIFIED_HEADERS_CEL_EXPRESSION),
238                ),
239                ("Accept-Encoding".into(), "gzip".into()),
240            ],
241        )
242        .build();
243
244        let result = response_hash(&response, &response_certification, None);
245        let result_without_excluded_headers = response_hash(
246            &response_without_excluded_headers,
247            &response_certification,
248            None,
249        );
250
251        assert_eq!(result, result_without_excluded_headers);
252    }
253
254    #[test]
255    fn response_hash_with_header_exclusions() {
256        let response_certification =
257            DefaultResponseCertification::response_header_exclusions(vec![
258                "Accept-Encoding",
259                "Cache-Control",
260            ]);
261        let response = create_response(HEADER_EXCLUSIONS_CEL_EXPRESSION);
262        let expected_hash =
263            hex::decode("a2ffb50ef8971650c2fb46c0a2788b7d5ac5a027d635175e8e06b419ce6c4cda")
264                .unwrap();
265
266        let result = response_hash(&response, &response_certification, None);
267
268        assert_eq!(result, expected_hash.as_slice());
269    }
270
271    #[test]
272    fn response_hash_with_header_exclusions_without_excluded_headers() {
273        let response_certification =
274            DefaultResponseCertification::response_header_exclusions(vec![
275                "Content-Security-Policy",
276            ]);
277        let response = create_response(HEADER_EXCLUSIONS_CEL_EXPRESSION);
278        let response_without_excluded_headers = HttpResponse::ok(
279            HELLO_WORLD_BODY,
280            vec![
281                (
282                    CERTIFICATE_EXPRESSION_HEADER_NAME.into(),
283                    remove_whitespace(HEADER_EXCLUSIONS_CEL_EXPRESSION),
284                ),
285                ("Accept-Encoding".into(), "gzip".into()),
286                ("Cache-Control".into(), "no-cache".into()),
287                ("Cache-Control".into(), "no-store".into()),
288            ],
289        )
290        .build();
291
292        let result = response_hash(&response, &response_certification, None);
293        let result_without_excluded_headers = response_hash(
294            &response_without_excluded_headers,
295            &response_certification,
296            None,
297        );
298
299        assert_eq!(result, result_without_excluded_headers);
300    }
301
302    #[test]
303    fn response_headers_hash_with_certified_headers() {
304        let response_certification =
305            DefaultResponseCertification::certified_response_headers(vec![
306                "Accept-Encoding",
307                "Cache-Control",
308            ]);
309        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
310        let expected_hash =
311            hex::decode("eac859a99d5bd7b71f46dbacecff4aaa0a7a7131802c136a77a76c8e018af5f7")
312                .unwrap();
313
314        let filtered_headers = filter_response_headers(&response, &response_certification);
315        let result =
316            response_headers_hash(&response.status_code().as_u16().into(), &filtered_headers);
317
318        assert_eq!(result, expected_hash.as_slice());
319    }
320
321    #[test]
322    fn response_headers_hash_with_certified_headers_without_excluded_headers() {
323        let response_certification =
324            DefaultResponseCertification::certified_response_headers(vec!["Accept-Encoding"]);
325        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
326        let response_without_excluded_headers = HttpResponse::ok(
327            HELLO_WORLD_BODY,
328            vec![
329                (CERTIFICATE_HEADER_NAME.into(), CERTIFICATE.into()),
330                (
331                    CERTIFICATE_EXPRESSION_HEADER_NAME.into(),
332                    remove_whitespace(CERTIFIED_HEADERS_CEL_EXPRESSION),
333                ),
334                ("Accept-Encoding".into(), "gzip".into()),
335            ],
336        )
337        .build();
338
339        let filtered_headers = filter_response_headers(&response, &response_certification);
340        let result =
341            response_headers_hash(&response.status_code().as_u16().into(), &filtered_headers);
342        let filtered_headers_without_excluded_headers =
343            filter_response_headers(&response_without_excluded_headers, &response_certification);
344        let result_without_excluded_headers = response_headers_hash(
345            &response_without_excluded_headers
346                .status_code()
347                .as_u16()
348                .into(),
349            &filtered_headers_without_excluded_headers,
350        );
351
352        assert_eq!(result, result_without_excluded_headers);
353    }
354
355    #[test]
356    fn response_headers_hash_with_header_exclusions() {
357        let response_certification =
358            DefaultResponseCertification::response_header_exclusions(vec![
359                "Accept-Encoding",
360                "Cache-Control",
361            ]);
362        let response = create_response(HEADER_EXCLUSIONS_CEL_EXPRESSION);
363        let expected_hash =
364            hex::decode("d618f70bf2578d5a672374ffbaade3910e858384f42d01ac2863946ab596bcac")
365                .unwrap();
366
367        let filtered_headers = filter_response_headers(&response, &response_certification);
368        let result =
369            response_headers_hash(&response.status_code().as_u16().into(), &filtered_headers);
370
371        assert_eq!(result, expected_hash.as_slice());
372    }
373
374    #[test]
375    fn response_headers_hash_with_header_exclusions_without_excluded_headers() {
376        let response_certification =
377            DefaultResponseCertification::response_header_exclusions(vec![
378                "Content-Security-Policy",
379            ]);
380        let response = create_response(HEADER_EXCLUSIONS_CEL_EXPRESSION);
381        let response_without_excluded_headers = HttpResponse::ok(
382            HELLO_WORLD_BODY,
383            vec![
384                (CERTIFICATE_HEADER_NAME.into(), CERTIFICATE.into()),
385                (
386                    CERTIFICATE_EXPRESSION_HEADER_NAME.into(),
387                    remove_whitespace(HEADER_EXCLUSIONS_CEL_EXPRESSION),
388                ),
389                ("Accept-Encoding".into(), "gzip".into()),
390                ("Cache-Control".into(), "no-cache".into()),
391                ("Cache-Control".into(), "no-store".into()),
392            ],
393        )
394        .build();
395
396        let filtered_headers = filter_response_headers(&response, &response_certification);
397        let result =
398            response_headers_hash(&response.status_code().as_u16().into(), &filtered_headers);
399
400        let response_headers_without_excluded_headers =
401            filter_response_headers(&response_without_excluded_headers, &response_certification);
402        let result_without_excluded_headers = response_headers_hash(
403            &response_without_excluded_headers
404                .status_code()
405                .as_u16()
406                .into(),
407            &response_headers_without_excluded_headers,
408        );
409
410        assert_eq!(result, result_without_excluded_headers);
411    }
412
413    #[test]
414    fn response_hash_with_body_hash_override() {
415        let response_certification =
416            DefaultResponseCertification::certified_response_headers(vec![
417                "Accept-Encoding",
418                "Cache-Control",
419            ]);
420        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
421        let response_body_hash: Hash =
422            hex::decode("5462fc394013080effc31d578ec3fff8b44cdf24738b38a77ce4afacbc93a7f5")
423                .unwrap()
424                .try_into()
425                .unwrap();
426        let expected_hash =
427            hex::decode("1afc744a377cb8785d1078f53f9bbc9160d86b7a05f490e42c89366326eaef20")
428                .unwrap();
429
430        let result = response_hash(&response, &response_certification, Some(response_body_hash));
431
432        assert_eq!(result, expected_hash.as_slice());
433    }
434
435    fn create_response(cel_expression: &str) -> HttpResponse {
436        HttpResponse::ok(
437            HELLO_WORLD_BODY,
438            vec![
439                (CERTIFICATE_HEADER_NAME.into(), CERTIFICATE.into()),
440                (
441                    CERTIFICATE_EXPRESSION_HEADER_NAME.into(),
442                    remove_whitespace(cel_expression),
443                ),
444                ("Accept-Encoding".into(), "gzip".into()),
445                ("Cache-Control".into(), "no-cache".into()),
446                ("Cache-Control".into(), "no-store".into()),
447                (
448                    "Content-Security-Policy".into(),
449                    "default-src 'self'".into(),
450                ),
451            ],
452        )
453        .build()
454    }
455
456    /// Remove white space from CEL expressions to ease the calculation
457    /// of the expected hashes. Generating the hash for a string with so much whitespace manually
458    /// may be prone to error in copy/pasting the string into a website and missing a leading/trailing
459    /// newline or a tab character somewhere.
460    fn remove_whitespace(s: &str) -> String {
461        s.chars().filter(|c| !c.is_whitespace()).collect()
462    }
463}