Skip to main content

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
11/// The pseudo-header name used for encoding the response status code in the
12/// [Representation Independent Hash](https://internetcomputer.org/docs/references/ic-interface-spec/#hash-of-map).
13pub const RESPONSE_STATUS_PSEUDO_HEADER_NAME: &str = ":ic-cert-status";
14
15/// Representation of response headers filtered by [filter_response_headers].
16#[derive(Debug)]
17pub struct ResponseHeaders {
18    /// Filtered headers
19    pub headers: Vec<(String, String)>,
20    /// IC-Certificate header
21    pub certificate: Option<String>,
22}
23
24/// Filters the headers of an [HttpResponse] according to a CEL expression defined by
25/// [DefaultResponseCertification].
26pub fn filter_response_headers(
27    response: &HttpResponse,
28    response_certification: &DefaultResponseCertification<'_>,
29) -> ResponseHeaders {
30    let headers_filter: Box<dyn Fn(_) -> _> = match response_certification.get_type() {
31        DefaultResponseCertificationType::CertifiedResponseHeaders(headers_to_include) => {
32            Box::new(move |header_name: &String| {
33                headers_to_include.iter().any(|header_to_include| {
34                    header_to_include.eq_ignore_ascii_case(&header_name.to_string())
35                })
36            })
37        }
38        DefaultResponseCertificationType::ResponseHeaderExclusions(headers_to_exclude) => {
39            Box::new(move |header_name: &String| {
40                !headers_to_exclude.iter().any(|header_to_exclude| {
41                    header_to_exclude.eq_ignore_ascii_case(&header_name.to_string())
42                })
43            })
44        }
45    };
46
47    let mut response_headers = ResponseHeaders {
48        headers: vec![],
49        certificate: None,
50    };
51
52    response_headers.headers = response
53        .headers()
54        .iter()
55        .filter_map(|(header_name, header_value)| {
56            let is_certificate_header = header_name
57                .to_string()
58                .eq_ignore_ascii_case(CERTIFICATE_HEADER_NAME);
59            if is_certificate_header {
60                response_headers.certificate = Some(header_value.into());
61                return None;
62            }
63
64            let is_certificate_expression_header = header_name
65                .to_string()
66                .eq_ignore_ascii_case(CERTIFICATE_EXPRESSION_HEADER_NAME);
67            if is_certificate_expression_header {
68                return Some((
69                    header_name.to_string().to_ascii_lowercase(),
70                    String::from(header_value),
71                ));
72            }
73
74            if headers_filter(header_name) {
75                return Some((
76                    header_name.to_string().to_ascii_lowercase(),
77                    String::from(header_value),
78                ));
79            }
80
81            None
82        })
83        .collect();
84
85    response_headers
86}
87
88/// Calculates the
89/// [Representation Independent Hash](https://internetcomputer.org/docs/references/ic-interface-spec/#hash-of-map)
90/// of [ResponseHeaders] that have been filtered with [filter_response_headers].
91pub fn response_headers_hash(status_code: &u64, response_headers: &ResponseHeaders) -> Hash {
92    let mut headers_to_verify: Vec<(String, Value)> = response_headers
93        .headers
94        .iter()
95        .map(|(header_name, header_value)| {
96            (
97                header_name.to_string(),
98                Value::String(String::from(header_value)),
99            )
100        })
101        .collect();
102
103    headers_to_verify.push((
104        RESPONSE_STATUS_PSEUDO_HEADER_NAME.into(),
105        Value::Number(*status_code),
106    ));
107
108    representation_independent_hash(&headers_to_verify)
109}
110
111/// Computes the v2 response hash from pre-built header pairs, a status code, and a body hash.
112///
113/// This is a lower-level variant of [`response_hash`] that operates on headers already
114/// represented as `(String, Value)` pairs (the format used by
115/// [`representation_independent_hash`]). It appends the `:ic-cert-status` pseudo-header
116/// internally.
117///
118/// The result is `SHA-256(header_hash || body_hash)` where `header_hash` is the
119/// representation-independent hash of the headers including the status pseudo-header.
120///
121/// # Expected input shape
122///
123/// To produce a hash that matches [`response_hash`], `certified_headers` must follow the
124/// same normalization that [`filter_response_headers`] applies:
125///
126/// - Header names must be ASCII-lowercased.
127/// - The `IC-Certificate` header must be excluded.
128/// - The `IC-CertificateExpression` header must be included (if present on the response).
129/// - The CEL `DefaultResponseCertification` filter (certified list or exclusion list)
130///   must already have been applied.
131/// - Values must be [`Value::String`] (no other [`Value`] variants are produced by
132///   [`response_headers_hash`] for header entries).
133///
134/// Callers that start from an [`HttpResponse`] and a [`DefaultResponseCertification`]
135/// should prefer [`response_hash`], which performs all of the above.
136///
137/// # Panics (debug builds)
138///
139/// Debug-asserts that `certified_headers` does not already contain
140/// [`RESPONSE_STATUS_PSEUDO_HEADER_NAME`]. Passing it would produce duplicate keys
141/// and a hash that does not match [`response_hash`]. In release builds this check is
142/// skipped; callers must uphold the precondition themselves.
143pub fn response_hash_from_headers(
144    certified_headers: &[(String, Value)],
145    status_code: u16,
146    body_hash: &Hash,
147) -> Hash {
148    debug_assert!(
149        !certified_headers
150            .iter()
151            .any(|(k, _)| k == RESPONSE_STATUS_PSEUDO_HEADER_NAME),
152        "certified_headers must not contain the status pseudo-header; it is added internally"
153    );
154    let mut headers = Vec::with_capacity(certified_headers.len() + 1);
155    headers.extend_from_slice(certified_headers);
156    headers.push((
157        RESPONSE_STATUS_PSEUDO_HEADER_NAME.into(),
158        Value::Number(status_code.into()),
159    ));
160    let header_hash = representation_independent_hash(&headers);
161    hash(
162        [header_hash.as_ref(), body_hash.as_ref()]
163            .concat()
164            .as_slice(),
165    )
166}
167
168/// Calculates the
169/// [Representation Independent Hash](https://internetcomputer.org/docs/references/ic-interface-spec/#hash-of-map)
170/// of an [HttpResponse] according to a CEL expression defined by [DefaultResponseCertification].
171///
172/// An optional response body hash may be provided if this is known beforehand. If this override is not
173/// provided then the response body hash will be calculated by this function.
174pub fn response_hash(
175    response: &HttpResponse,
176    response_certification: &DefaultResponseCertification,
177    response_body_hash: Option<Hash>,
178) -> Hash {
179    let response_body_hash = response_body_hash.unwrap_or_else(|| hash(response.body()));
180
181    let filtered_headers = filter_response_headers(response, response_certification);
182    let concatenated_hashes = [
183        response_headers_hash(&response.status_code().as_u16().into(), &filtered_headers),
184        response_body_hash,
185    ]
186    .concat();
187
188    hash(concatenated_hashes.as_slice())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    const HELLO_WORLD_BODY: &[u8] = &[72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33];
196    const CERTIFICATE: &str = "certificate=:SGVsbG8gQ2VydGlmaWNhdGUh:,tree=:SGVsbG8gVHJlZSE=:";
197    const HEADER_EXCLUSIONS_CEL_EXPRESSION: &str = r#"
198        default_certification (
199          ValidationArgs {
200            certification: Certification {
201              no_request_certification: Empty {},
202              response_certification: ResponseCertification {
203                response_header_exclusions: ResponseHeaderList {
204                  headers: ["Content-Security-Policy"]
205                }
206              }
207            }
208          }
209        )
210    "#;
211    const CERTIFIED_HEADERS_CEL_EXPRESSION: &str = r#"
212        default_certification (
213          ValidationArgs {
214            certification: Certification {
215              no_request_certification: Empty {},
216              response_certification: ResponseCertification {
217                certified_response_headers: ResponseHeaderList {
218                  headers: ["Accept-Encoding", "Cache-Control"]
219                }
220              }
221            }
222          }
223        )
224    "#;
225
226    #[test]
227    fn response_with_certified_headers_without_excluded_headers() {
228        let response_certification =
229            DefaultResponseCertification::certified_response_headers(vec!["Accept-Encoding"]);
230        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
231        let response_headers = filter_response_headers(&response, &response_certification);
232
233        assert_eq!(
234            response_headers.headers,
235            vec![
236                (
237                    CERTIFICATE_EXPRESSION_HEADER_NAME.to_lowercase(),
238                    remove_whitespace(CERTIFIED_HEADERS_CEL_EXPRESSION),
239                ),
240                ("accept-encoding".into(), "gzip".into()),
241            ]
242        );
243    }
244
245    #[test]
246    fn response_with_certified_headers() {
247        let response_certification =
248            DefaultResponseCertification::certified_response_headers(vec![
249                "Accept-Encoding",
250                "Cache-Control",
251            ]);
252        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
253        let response_headers = filter_response_headers(&response, &response_certification);
254
255        assert_eq!(
256            response_headers.headers,
257            vec![
258                (
259                    CERTIFICATE_EXPRESSION_HEADER_NAME.to_lowercase(),
260                    remove_whitespace(CERTIFIED_HEADERS_CEL_EXPRESSION),
261                ),
262                ("accept-encoding".into(), "gzip".into()),
263                ("cache-control".into(), "no-cache".into()),
264                ("cache-control".into(), "no-store".into()),
265            ]
266        );
267    }
268
269    #[test]
270    fn response_hash_with_certified_headers() {
271        let response_certification =
272            DefaultResponseCertification::certified_response_headers(vec![
273                "Accept-Encoding",
274                "Cache-Control",
275            ]);
276        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
277        let expected_hash =
278            hex::decode("3393250e3cedc30408dcb7e8963898c3d7549b8a0b76496b82fdfeae99c2ac78")
279                .unwrap();
280
281        let result = response_hash(&response, &response_certification, None);
282
283        assert_eq!(result, expected_hash.as_slice());
284    }
285
286    #[test]
287    fn response_hash_with_certified_headers_without_excluded_headers() {
288        let response_certification =
289            DefaultResponseCertification::certified_response_headers(vec!["Accept-Encoding"]);
290        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
291        let response_without_excluded_headers = HttpResponse::ok(
292            HELLO_WORLD_BODY,
293            vec![
294                (
295                    CERTIFICATE_EXPRESSION_HEADER_NAME.into(),
296                    remove_whitespace(CERTIFIED_HEADERS_CEL_EXPRESSION),
297                ),
298                ("Accept-Encoding".into(), "gzip".into()),
299            ],
300        )
301        .build();
302
303        let result = response_hash(&response, &response_certification, None);
304        let result_without_excluded_headers = response_hash(
305            &response_without_excluded_headers,
306            &response_certification,
307            None,
308        );
309
310        assert_eq!(result, result_without_excluded_headers);
311    }
312
313    #[test]
314    fn response_hash_with_header_exclusions() {
315        let response_certification =
316            DefaultResponseCertification::response_header_exclusions(vec![
317                "Accept-Encoding",
318                "Cache-Control",
319            ]);
320        let response = create_response(HEADER_EXCLUSIONS_CEL_EXPRESSION);
321        let expected_hash =
322            hex::decode("a2ffb50ef8971650c2fb46c0a2788b7d5ac5a027d635175e8e06b419ce6c4cda")
323                .unwrap();
324
325        let result = response_hash(&response, &response_certification, None);
326
327        assert_eq!(result, expected_hash.as_slice());
328    }
329
330    #[test]
331    fn response_hash_with_header_exclusions_without_excluded_headers() {
332        let response_certification =
333            DefaultResponseCertification::response_header_exclusions(vec![
334                "Content-Security-Policy",
335            ]);
336        let response = create_response(HEADER_EXCLUSIONS_CEL_EXPRESSION);
337        let response_without_excluded_headers = HttpResponse::ok(
338            HELLO_WORLD_BODY,
339            vec![
340                (
341                    CERTIFICATE_EXPRESSION_HEADER_NAME.into(),
342                    remove_whitespace(HEADER_EXCLUSIONS_CEL_EXPRESSION),
343                ),
344                ("Accept-Encoding".into(), "gzip".into()),
345                ("Cache-Control".into(), "no-cache".into()),
346                ("Cache-Control".into(), "no-store".into()),
347            ],
348        )
349        .build();
350
351        let result = response_hash(&response, &response_certification, None);
352        let result_without_excluded_headers = response_hash(
353            &response_without_excluded_headers,
354            &response_certification,
355            None,
356        );
357
358        assert_eq!(result, result_without_excluded_headers);
359    }
360
361    #[test]
362    fn response_headers_hash_with_certified_headers() {
363        let response_certification =
364            DefaultResponseCertification::certified_response_headers(vec![
365                "Accept-Encoding",
366                "Cache-Control",
367            ]);
368        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
369        let expected_hash =
370            hex::decode("eac859a99d5bd7b71f46dbacecff4aaa0a7a7131802c136a77a76c8e018af5f7")
371                .unwrap();
372
373        let filtered_headers = filter_response_headers(&response, &response_certification);
374        let result =
375            response_headers_hash(&response.status_code().as_u16().into(), &filtered_headers);
376
377        assert_eq!(result, expected_hash.as_slice());
378    }
379
380    #[test]
381    fn response_headers_hash_with_certified_headers_without_excluded_headers() {
382        let response_certification =
383            DefaultResponseCertification::certified_response_headers(vec!["Accept-Encoding"]);
384        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
385        let response_without_excluded_headers = HttpResponse::ok(
386            HELLO_WORLD_BODY,
387            vec![
388                (CERTIFICATE_HEADER_NAME.into(), CERTIFICATE.into()),
389                (
390                    CERTIFICATE_EXPRESSION_HEADER_NAME.into(),
391                    remove_whitespace(CERTIFIED_HEADERS_CEL_EXPRESSION),
392                ),
393                ("Accept-Encoding".into(), "gzip".into()),
394            ],
395        )
396        .build();
397
398        let filtered_headers = filter_response_headers(&response, &response_certification);
399        let result =
400            response_headers_hash(&response.status_code().as_u16().into(), &filtered_headers);
401        let filtered_headers_without_excluded_headers =
402            filter_response_headers(&response_without_excluded_headers, &response_certification);
403        let result_without_excluded_headers = response_headers_hash(
404            &response_without_excluded_headers
405                .status_code()
406                .as_u16()
407                .into(),
408            &filtered_headers_without_excluded_headers,
409        );
410
411        assert_eq!(result, result_without_excluded_headers);
412    }
413
414    #[test]
415    fn response_headers_hash_with_header_exclusions() {
416        let response_certification =
417            DefaultResponseCertification::response_header_exclusions(vec![
418                "Accept-Encoding",
419                "Cache-Control",
420            ]);
421        let response = create_response(HEADER_EXCLUSIONS_CEL_EXPRESSION);
422        let expected_hash =
423            hex::decode("d618f70bf2578d5a672374ffbaade3910e858384f42d01ac2863946ab596bcac")
424                .unwrap();
425
426        let filtered_headers = filter_response_headers(&response, &response_certification);
427        let result =
428            response_headers_hash(&response.status_code().as_u16().into(), &filtered_headers);
429
430        assert_eq!(result, expected_hash.as_slice());
431    }
432
433    #[test]
434    fn response_headers_hash_with_header_exclusions_without_excluded_headers() {
435        let response_certification =
436            DefaultResponseCertification::response_header_exclusions(vec![
437                "Content-Security-Policy",
438            ]);
439        let response = create_response(HEADER_EXCLUSIONS_CEL_EXPRESSION);
440        let response_without_excluded_headers = HttpResponse::ok(
441            HELLO_WORLD_BODY,
442            vec![
443                (CERTIFICATE_HEADER_NAME.into(), CERTIFICATE.into()),
444                (
445                    CERTIFICATE_EXPRESSION_HEADER_NAME.into(),
446                    remove_whitespace(HEADER_EXCLUSIONS_CEL_EXPRESSION),
447                ),
448                ("Accept-Encoding".into(), "gzip".into()),
449                ("Cache-Control".into(), "no-cache".into()),
450                ("Cache-Control".into(), "no-store".into()),
451            ],
452        )
453        .build();
454
455        let filtered_headers = filter_response_headers(&response, &response_certification);
456        let result =
457            response_headers_hash(&response.status_code().as_u16().into(), &filtered_headers);
458
459        let response_headers_without_excluded_headers =
460            filter_response_headers(&response_without_excluded_headers, &response_certification);
461        let result_without_excluded_headers = response_headers_hash(
462            &response_without_excluded_headers
463                .status_code()
464                .as_u16()
465                .into(),
466            &response_headers_without_excluded_headers,
467        );
468
469        assert_eq!(result, result_without_excluded_headers);
470    }
471
472    #[test]
473    fn response_hash_with_body_hash_override() {
474        let response_certification =
475            DefaultResponseCertification::certified_response_headers(vec![
476                "Accept-Encoding",
477                "Cache-Control",
478            ]);
479        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
480        let response_body_hash: Hash =
481            hex::decode("5462fc394013080effc31d578ec3fff8b44cdf24738b38a77ce4afacbc93a7f5")
482                .unwrap()
483                .try_into()
484                .unwrap();
485        let expected_hash =
486            hex::decode("1afc744a377cb8785d1078f53f9bbc9160d86b7a05f490e42c89366326eaef20")
487                .unwrap();
488
489        let result = response_hash(&response, &response_certification, Some(response_body_hash));
490
491        assert_eq!(result, expected_hash.as_slice());
492    }
493
494    #[test]
495    fn response_hash_from_headers_matches_response_hash() {
496        let response_certification =
497            DefaultResponseCertification::certified_response_headers(vec![
498                "Accept-Encoding",
499                "Cache-Control",
500            ]);
501        let response = create_response(CERTIFIED_HEADERS_CEL_EXPRESSION);
502        let expected = response_hash(&response, &response_certification, None);
503
504        let filtered = filter_response_headers(&response, &response_certification);
505        let header_pairs: Vec<(String, Value)> = filtered
506            .headers
507            .iter()
508            .map(|(k, v)| (k.clone(), Value::String(v.clone())))
509            .collect();
510        let body_hash = hash(response.body());
511        let result =
512            response_hash_from_headers(&header_pairs, response.status_code().as_u16(), &body_hash);
513
514        assert_eq!(result, expected);
515    }
516
517    fn create_response(cel_expression: &str) -> HttpResponse {
518        HttpResponse::ok(
519            HELLO_WORLD_BODY,
520            vec![
521                (CERTIFICATE_HEADER_NAME.into(), CERTIFICATE.into()),
522                (
523                    CERTIFICATE_EXPRESSION_HEADER_NAME.into(),
524                    remove_whitespace(cel_expression),
525                ),
526                ("Accept-Encoding".into(), "gzip".into()),
527                ("Cache-Control".into(), "no-cache".into()),
528                ("Cache-Control".into(), "no-store".into()),
529                (
530                    "Content-Security-Policy".into(),
531                    "default-src 'self'".into(),
532                ),
533            ],
534        )
535        .build()
536    }
537
538    /// Remove white space from CEL expressions to ease the calculation
539    /// of the expected hashes. Generating the hash for a string with so much whitespace manually
540    /// may be prone to error in copy/pasting the string into a website and missing a leading/trailing
541    /// newline or a tab character somewhere.
542    fn remove_whitespace(s: &str) -> String {
543        s.chars().filter(|c| !c.is_whitespace()).collect()
544    }
545}