Skip to main content

mkit_core/
admission.rs

1//! Bounded, transport-neutral admission challenges (STC §5.1).
2
3/// Maximum number of challenges in one decision.
4pub const MAX_CHALLENGES: usize = 8;
5/// Maximum ASCII scheme length.
6pub const MAX_SCHEME_BYTES: usize = 64;
7/// Maximum challenge value length.
8pub const MAX_VALUE_BYTES: usize = 8_192;
9/// Maximum human-readable description length.
10pub const MAX_DESCRIPTION_BYTES: usize = 512;
11
12/// A violation of the public challenge bounds. No credential content is retained.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum BoundError {
15    /// The challenge count is outside 1–8.
16    Count,
17    /// A scheme is not a lowercase token of at most 64 bytes.
18    Scheme,
19    /// A value is too long or contains a forbidden control byte.
20    Value,
21    /// A description is too long or contains a forbidden control byte.
22    Description,
23}
24
25/// The STC lowercase scheme grammar.
26#[must_use]
27pub fn is_valid_scheme(scheme: &str) -> bool {
28    let bytes = scheme.as_bytes();
29    (1..=MAX_SCHEME_BYTES).contains(&bytes.len())
30        && (bytes[0].is_ascii_lowercase() || bytes[0].is_ascii_digit())
31        && bytes[1..]
32            .iter()
33            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || b".-".contains(byte))
34}
35
36/// Validate an ordered challenge list and its description.
37///
38/// # Errors
39/// Returns the first violated bound without echoing the offending value.
40pub fn validate_challenges(
41    challenges: &[(&str, &str)],
42    description: &str,
43) -> Result<(), BoundError> {
44    if !(1..=MAX_CHALLENGES).contains(&challenges.len()) {
45        return Err(BoundError::Count);
46    }
47    for (scheme, value) in challenges {
48        if !is_valid_scheme(scheme) {
49            return Err(BoundError::Scheme);
50        }
51        if value.len() > MAX_VALUE_BYTES || value.chars().any(forbidden_control) {
52            return Err(BoundError::Value);
53        }
54    }
55    if description.len() > MAX_DESCRIPTION_BYTES || description.chars().any(forbidden_control) {
56        return Err(BoundError::Description);
57    }
58    Ok(())
59}
60
61fn forbidden_control(ch: char) -> bool {
62    ch != '\t' && ch.is_control()
63}
64
65fn varint(mut value: usize, out: &mut Vec<u8>) {
66    while value >= 0x80 {
67        out.push((value.to_le_bytes()[0] & 0x7f) | 0x80);
68        value >>= 7;
69    }
70    out.push(value.to_le_bytes()[0]);
71}
72
73fn field(number: u8, value: &[u8], out: &mut Vec<u8>) {
74    out.push((number << 3) | 2);
75    varint(value.len(), out);
76    out.extend_from_slice(value);
77}
78
79/// Encode the validated STC protobuf detail without a protobuf dependency.
80#[must_use]
81pub fn encode_admission_challenge(challenges: &[(&str, &str)], description: &str) -> Vec<u8> {
82    let mut out = Vec::new();
83    for (scheme, value) in challenges {
84        let mut entry = Vec::new();
85        if !scheme.is_empty() {
86            field(1, scheme.as_bytes(), &mut entry);
87        }
88        if !value.is_empty() {
89            field(2, value.as_bytes(), &mut entry);
90        }
91        field(1, &entry, &mut out);
92    }
93    if !description.is_empty() {
94        field(2, description.as_bytes(), &mut out);
95    }
96    out
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn golden_bytes() {
105        let challenges = [
106            (
107                "mpp",
108                "Payment id=\"fake-example-not-valid\", method=\"tempo\", intent=\"charge\", request=\"fake-example-request-not-valid\"",
109            ),
110            ("x402", "fake-example-payment-required-not-valid"),
111        ];
112        assert_eq!(
113            encode_admission_challenge(&challenges, "Example upload payment required."),
114            include_bytes!("../../../tests/golden/transport/admission-challenge.bin")
115        );
116    }
117
118    #[test]
119    fn bounds() {
120        for good in ["a", "0", "mpp.v1-2", &"a".repeat(64)] {
121            assert!(is_valid_scheme(good));
122        }
123        for bad in ["", "A", "mpp_1", "-mpp", &"a".repeat(65)] {
124            assert!(!is_valid_scheme(bad));
125        }
126        assert_eq!(validate_challenges(&[], ""), Err(BoundError::Count));
127        assert!(validate_challenges(&vec![("mpp", "v"); 8], "").is_ok());
128        assert_eq!(
129            validate_challenges(&vec![("mpp", "v"); 9], ""),
130            Err(BoundError::Count)
131        );
132        assert!(validate_challenges(&[("mpp", &"v".repeat(8192))], &"d".repeat(512)).is_ok());
133        assert_eq!(
134            validate_challenges(&[("mpp", &"v".repeat(8193))], ""),
135            Err(BoundError::Value)
136        );
137        assert_eq!(
138            validate_challenges(&[("mpp", "v")], &"d".repeat(513)),
139            Err(BoundError::Description)
140        );
141        assert_eq!(
142            validate_challenges(&[("mpp", "a\r")], ""),
143            Err(BoundError::Value)
144        );
145        assert_eq!(
146            validate_challenges(&[("mpp", "a")], "a\n"),
147            Err(BoundError::Description)
148        );
149        assert_eq!(
150            validate_challenges(&[("mpp", "a\u{85}")], ""),
151            Err(BoundError::Value)
152        );
153    }
154}