Skip to main content

herolib_crypt/httpsig/
parser.rs

1//! RFC 9421 Signature-Input and Signature header parsing.
2
3use crate::httpsig::error::HttpSigError;
4use base64::Engine;
5
6/// Signature parameters extracted from Signature-Input header.
7#[derive(Debug, Clone)]
8pub struct SignatureParams {
9    pub label: String,
10    pub components: Vec<String>,
11    pub keyid: String,
12    pub alg: String,
13    pub created: u64,
14}
15
16/// Extract the key ID from a Signature-Input header value.
17///
18/// # Example
19///
20/// ```
21/// use herolib_crypt::httpsig::extract_key_id;
22///
23/// let header = r#"sig1=("@method" "@path");keyid="user-123";alg="ed25519";created=1234567890"#;
24/// let key_id = extract_key_id(header).unwrap();
25/// assert_eq!(key_id, "user-123");
26/// ```
27pub fn extract_key_id(signature_input: &str) -> Result<String, HttpSigError> {
28    let params = parse_signature_input(signature_input)?;
29    Ok(params.keyid)
30}
31
32/// Parse a Signature-Input header value.
33///
34/// Expected format:
35/// ```text
36/// sig1=("@method" "@path" "content-digest");keyid="user-123";alg="ed25519";created=1234567890
37/// ```
38pub fn parse_signature_input(header: &str) -> Result<SignatureParams, HttpSigError> {
39    // Split into label and rest
40    let parts: Vec<&str> = header.splitn(2, '=').collect();
41    if parts.len() != 2 {
42        return Err(HttpSigError::ParseError(
43            "Invalid Signature-Input format".to_string(),
44        ));
45    }
46
47    let label = parts[0].trim().to_string();
48    let rest = parts[1].trim();
49
50    // Extract components list (between parentheses)
51    let components = parse_components_list(rest)?;
52
53    // Extract parameters after the closing parenthesis
54    let params_start = rest.find(");").ok_or_else(|| {
55        HttpSigError::ParseError("Missing closing parenthesis in component list".to_string())
56    })? + 2;
57
58    let params_str = &rest[params_start..];
59
60    // Parse parameters
61    let keyid = extract_param(params_str, "keyid")?;
62    let alg = extract_param(params_str, "alg")?;
63    let created_str = extract_param(params_str, "created")?;
64    let created = created_str.parse::<u64>().map_err(|_| {
65        HttpSigError::ParseError(format!("Invalid created timestamp: {}", created_str))
66    })?;
67
68    Ok(SignatureParams {
69        label,
70        components,
71        keyid,
72        alg,
73        created,
74    })
75}
76
77/// Parse the component list from a Signature-Input header.
78///
79/// Extracts components from: `("@method" "@path" "content-digest")`
80fn parse_components_list(input: &str) -> Result<Vec<String>, HttpSigError> {
81    let start = input.find('(').ok_or_else(|| {
82        HttpSigError::ParseError("Missing opening parenthesis in component list".to_string())
83    })?;
84
85    let end = input.find(')').ok_or_else(|| {
86        HttpSigError::ParseError("Missing closing parenthesis in component list".to_string())
87    })?;
88
89    let list_str = &input[start + 1..end];
90
91    let components: Vec<String> = list_str
92        .split_whitespace()
93        .map(|s| s.trim_matches('"').to_string())
94        .filter(|s| !s.is_empty())
95        .collect();
96
97    if components.is_empty() {
98        return Err(HttpSigError::ParseError(
99            "Empty component list".to_string(),
100        ));
101    }
102
103    Ok(components)
104}
105
106/// Extract a parameter value from the parameter string.
107///
108/// Handles both quoted and unquoted values.
109fn extract_param(params: &str, name: &str) -> Result<String, HttpSigError> {
110    let pattern = format!("{}=", name);
111    let start = params
112        .find(&pattern)
113        .ok_or_else(|| HttpSigError::ParseError(format!("Missing parameter: {}", name)))?
114        + pattern.len();
115
116    let rest = &params[start..];
117
118    // Check if value is quoted
119    if rest.starts_with('"') {
120        let end = rest[1..]
121            .find('"')
122            .ok_or_else(|| HttpSigError::ParseError(format!("Unclosed quote for {}", name)))?
123            + 1;
124        Ok(rest[1..end].to_string())
125    } else {
126        // Unquoted value - read until semicolon or end
127        let end = rest.find(';').unwrap_or(rest.len());
128        Ok(rest[..end].trim().to_string())
129    }
130}
131
132/// Parse a Signature header value and extract the signature bytes for a given label.
133///
134/// Expected format: `sig1=:base64_signature:`
135pub fn parse_signature(header: &str, label: &str) -> Result<Vec<u8>, HttpSigError> {
136    let pattern = format!("{}=:", label);
137    let start = header.find(&pattern).ok_or_else(|| {
138        HttpSigError::InvalidSignature(format!("Signature label '{}' not found", label))
139    })? + pattern.len();
140
141    let rest = &header[start..];
142    let end = rest
143        .find(':')
144        .ok_or_else(|| HttpSigError::InvalidSignature("Missing closing colon".to_string()))?;
145
146    let b64_sig = &rest[..end];
147
148    base64::engine::general_purpose::STANDARD
149        .decode(b64_sig)
150        .map_err(|e| HttpSigError::InvalidSignature(format!("Invalid base64: {}", e)))
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn test_parse_signature_input() {
159        let header = r#"sig1=("@method" "@path" "content-digest");keyid="user-123";alg="ed25519";created=1234567890"#;
160        let params = parse_signature_input(header).unwrap();
161
162        assert_eq!(params.label, "sig1");
163        assert_eq!(params.components.len(), 3);
164        assert_eq!(params.components[0], "@method");
165        assert_eq!(params.components[1], "@path");
166        assert_eq!(params.components[2], "content-digest");
167        assert_eq!(params.keyid, "user-123");
168        assert_eq!(params.alg, "ed25519");
169        assert_eq!(params.created, 1234567890);
170    }
171
172    #[test]
173    fn test_extract_key_id() {
174        let header = r#"sig1=("@method");keyid="test-key";alg="ed25519";created=123"#;
175        let key_id = extract_key_id(header).unwrap();
176        assert_eq!(key_id, "test-key");
177    }
178
179    #[test]
180    fn test_parse_components_list() {
181        let input = r#"("@method" "@path" "content-digest");keyid="x""#;
182        let components = parse_components_list(input).unwrap();
183
184        assert_eq!(components.len(), 3);
185        assert_eq!(components[0], "@method");
186        assert_eq!(components[1], "@path");
187        assert_eq!(components[2], "content-digest");
188    }
189
190    #[test]
191    fn test_extract_param_quoted() {
192        let params = r#"keyid="user-123";alg="ed25519""#;
193        assert_eq!(extract_param(params, "keyid").unwrap(), "user-123");
194        assert_eq!(extract_param(params, "alg").unwrap(), "ed25519");
195    }
196
197    #[test]
198    fn test_extract_param_unquoted() {
199        let params = "created=1234567890;other=value";
200        assert_eq!(extract_param(params, "created").unwrap(), "1234567890");
201    }
202
203    #[test]
204    fn test_parse_signature() {
205        let header = "sig1=:YWJjZGVm:";
206        let sig_bytes = parse_signature(header, "sig1").unwrap();
207        assert_eq!(sig_bytes, b"abcdef");
208    }
209
210    #[test]
211    fn test_parse_signature_invalid_label() {
212        let header = "sig1=:YWJjZGVm:";
213        let result = parse_signature(header, "sig2");
214        assert!(matches!(result, Err(HttpSigError::InvalidSignature(_))));
215    }
216}