Skip to main content

r402_protocol/
echo.rs

1//! Client extension-echo validation (official `validateExtensions`).
2
3use serde_json::Value;
4
5use crate::error::VerificationError;
6use crate::payment::{ExtensionEntry, Extensions};
7
8/// Official `SERVER_OWNED_INFO_FIELDS` for `builder-code`.
9const BUILDER_CODE: &str = "builder-code";
10const SERVER_OWNED_A: &str = "a";
11const ADDITIVE_S: &str = "s";
12const ADDITIVE_S_MAX: usize = 10;
13
14/// Validates v2 client extension echoes against the 402 advertisement.
15///
16/// Empty client extensions pass. Server-owned `builder-code.a` must match
17/// advertised `info.a` (including when the server did not declare `a`).
18/// Additive `builder-code.s` must be a superset of advertised `s` and must
19/// not exceed 10 entries.
20///
21/// # Errors
22///
23/// [`VerificationError::ExtensionEchoMismatch`] with the failing key.
24pub fn validate_extension_echoes(
25    advertised: Option<&Extensions>,
26    payload: &Extensions,
27    dynamic_info_fields: impl Fn(&str) -> &'static [&'static str],
28) -> Result<(), VerificationError> {
29    if payload.is_empty() {
30        return Ok(());
31    }
32    for (key, echoed_entry) in payload.iter() {
33        let advertised_entry = advertised.and_then(|ext| ext.get(key.as_str()));
34        let advertised_info = advertised_entry.map(extension_info);
35        let echoed_info = extension_info(echoed_entry);
36
37        if advertised_entry.is_some() {
38            let dynamic = dynamic_info_fields(key.as_str());
39            let advertised_cmp = omit_fields(advertised_info, dynamic);
40            let echoed_cmp = omit_fields(Some(echoed_info), dynamic);
41            if !info_matches_advertised(key.as_str(), advertised_cmp.as_ref(), echoed_cmp.as_ref())
42            {
43                return Err(echo_mismatch(key.as_str()));
44            }
45        }
46
47        if !server_owned_fields_match(key.as_str(), advertised_info, echoed_info) {
48            return Err(echo_mismatch(key.as_str()));
49        }
50    }
51    Ok(())
52}
53
54fn echo_mismatch(key: &str) -> VerificationError {
55    VerificationError::ExtensionEchoMismatch {
56        extension_key: key.to_owned(),
57    }
58}
59
60fn extension_info(entry: &ExtensionEntry) -> &Value {
61    match entry {
62        ExtensionEntry::Structured { info, .. } => info,
63        ExtensionEntry::Raw(value) => value.get("info").unwrap_or(value),
64    }
65}
66
67fn omit_fields(info: Option<&Value>, fields: &[&str]) -> Option<Value> {
68    let info = info?;
69    if fields.is_empty() {
70        return Some(info.clone());
71    }
72    let Value::Object(map) = info else {
73        return Some(info.clone());
74    };
75    let mut copy = map.clone();
76    for field in fields {
77        let _ = copy.remove(*field);
78    }
79    Some(Value::Object(copy))
80}
81
82fn server_owned_fields_match(key: &str, advertised: Option<&Value>, echoed: &Value) -> bool {
83    if key != BUILDER_CODE {
84        return true;
85    }
86    let Value::Object(echoed_map) = echoed else {
87        return true;
88    };
89    if !echoed_map.contains_key(SERVER_OWNED_A) {
90        return true;
91    }
92    let Some(echoed_a) = echoed_map.get(SERVER_OWNED_A) else {
93        return true;
94    };
95    if echoed_a.is_null() {
96        return true;
97    }
98    let advertised_a = advertised.and_then(|v| v.get(SERVER_OWNED_A));
99    advertised_a == Some(echoed_a)
100}
101
102fn info_matches_advertised(key: &str, advertised: Option<&Value>, echoed: Option<&Value>) -> bool {
103    object_contains_subset(advertised, echoed, key, None)
104}
105
106fn object_contains_subset(
107    expected: Option<&Value>,
108    actual: Option<&Value>,
109    extension_key: &str,
110    field_key: Option<&str>,
111) -> bool {
112    if let Some(field) = field_key
113        && extension_key == BUILDER_CODE
114        && field == ADDITIVE_S
115        && expected.is_some_and(is_array_or_scalar)
116        && actual.is_some_and(is_array_or_scalar)
117    {
118        return additive_array_contains(expected, actual);
119    }
120
121    let Some(expected) = expected else {
122        return true;
123    };
124    if !expected.is_object() {
125        return actual == Some(expected);
126    }
127    let Some(actual) = actual else {
128        return false;
129    };
130    let Some(expected_map) = expected.as_object() else {
131        return actual == expected;
132    };
133    let Some(actual_map) = actual.as_object() else {
134        return false;
135    };
136    expected_map.iter().all(|(k, value)| {
137        if !actual_map.contains_key(k) {
138            return value.is_null();
139        }
140        object_contains_subset(
141            Some(value),
142            actual_map.get(k),
143            extension_key,
144            Some(k.as_str()),
145        )
146    })
147}
148
149fn is_array_or_scalar(value: &Value) -> bool {
150    comparable_array(value).is_some()
151}
152
153fn comparable_array(value: &Value) -> Option<Vec<&Value>> {
154    match value {
155        Value::Array(items) => Some(items.iter().collect()),
156        Value::Null | Value::Object(_) => None,
157        other => Some(vec![other]),
158    }
159}
160
161fn additive_array_contains(expected: Option<&Value>, actual: Option<&Value>) -> bool {
162    let Some(expected) = expected.and_then(comparable_array) else {
163        return false;
164    };
165    let Some(actual) = actual.and_then(comparable_array) else {
166        return false;
167    };
168    if actual.len() > ADDITIVE_S_MAX {
169        return false;
170    }
171    expected
172        .iter()
173        .all(|exp| actual.iter().any(|act| act == exp))
174}
175
176#[cfg(test)]
177mod tests {
178    use serde_json::json;
179
180    use super::*;
181    use crate::payment::ExtensionEntry;
182
183    fn none_dyn(_: &str) -> &'static [&'static str] {
184        &[]
185    }
186
187    fn advertised(info: Value) -> Extensions {
188        let mut ext = Extensions::new();
189        ext.insert("builder-code", ExtensionEntry::info(info));
190        ext
191    }
192
193    fn payload(info: Value) -> Extensions {
194        advertised(info)
195    }
196
197    #[test]
198    fn empty_client_extensions_pass() {
199        assert!(validate_extension_echoes(None, &Extensions::new(), none_dyn).is_ok());
200    }
201
202    #[test]
203    fn forged_a_without_server_declaration_fails() {
204        let err = validate_extension_echoes(None, &payload(json!({"a": "forged_app"})), none_dyn)
205            .unwrap_err();
206        assert!(matches!(
207            err,
208            VerificationError::ExtensionEchoMismatch { .. }
209        ));
210    }
211
212    #[test]
213    fn client_s_without_declaration_passes() {
214        assert!(
215            validate_extension_echoes(None, &payload(json!({"s": ["bc_client"]})), none_dyn)
216                .is_ok()
217        );
218    }
219
220    #[test]
221    fn mismatched_a_fails() {
222        let err = validate_extension_echoes(
223            Some(&advertised(json!({"a": "bc_app"}))),
224            &payload(json!({"a": "forged_app"})),
225            none_dyn,
226        )
227        .unwrap_err();
228        assert!(matches!(
229            err,
230            VerificationError::ExtensionEchoMismatch { extension_key } if extension_key == "builder-code"
231        ));
232    }
233
234    #[test]
235    fn matching_a_and_superset_s_passes() {
236        assert!(
237            validate_extension_echoes(
238                Some(&advertised(json!({"a": "bc_app", "s": ["bc_server"]}))),
239                &payload(json!({"a": "bc_app", "s": ["bc_server", "bc_client"]})),
240                none_dyn,
241            )
242            .is_ok()
243        );
244    }
245
246    #[test]
247    fn dropped_s_entry_fails() {
248        assert!(
249            validate_extension_echoes(
250                Some(&advertised(json!({"s": ["bc_server"]}))),
251                &payload(json!({"s": ["bc_client"]})),
252                none_dyn,
253            )
254            .is_err()
255        );
256    }
257
258    #[test]
259    fn padded_s_over_budget_fails() {
260        let mut padded = vec!["bc_server".to_owned()];
261        padded.extend((0..10).map(|i| format!("bc_fake_{i}")));
262        assert!(
263            validate_extension_echoes(
264                Some(&advertised(json!({"s": ["bc_server"]}))),
265                &payload(json!({"s": padded})),
266                none_dyn,
267            )
268            .is_err()
269        );
270    }
271
272    #[test]
273    fn s_at_budget_passes() {
274        let mut at_budget = vec!["bc_server".to_owned()];
275        at_budget.extend((0..9).map(|i| format!("bc_client_{i}")));
276        assert!(
277            validate_extension_echoes(
278                Some(&advertised(json!({"s": ["bc_server"]}))),
279                &payload(json!({"s": at_budget})),
280                none_dyn,
281            )
282            .is_ok()
283        );
284    }
285
286    #[test]
287    fn scalar_s_merges_against_array() {
288        assert!(
289            validate_extension_echoes(
290                Some(&advertised(json!({"s": "bc_server"}))),
291                &payload(json!({"s": ["bc_server", "bc_client"]})),
292                none_dyn,
293            )
294            .is_ok()
295        );
296    }
297
298    #[test]
299    fn dynamic_offers_field_is_omitted() {
300        fn offers_dyn(key: &str) -> &'static [&'static str] {
301            if key == "offer-receipt" {
302                &["offers"]
303            } else {
304                &[]
305            }
306        }
307        let mut advertised = Extensions::new();
308        advertised.insert(
309            "offer-receipt",
310            ExtensionEntry::info(json!({"offers": [1], "kid": "x"})),
311        );
312        let mut payload = Extensions::new();
313        payload.insert(
314            "offer-receipt",
315            ExtensionEntry::info(json!({"offers": [2], "kid": "x"})),
316        );
317        assert!(validate_extension_echoes(Some(&advertised), &payload, offers_dyn).is_ok());
318    }
319}