#[allow(dead_code)]
pub(crate) struct SignedStringComponents {
pub(crate) method: String,
pub(crate) path: String,
pub(crate) created: i64,
pub(crate) expires: i64,
pub(crate) digest: Option<String>,
pub(crate) extra_headers: Vec<(String, String)>,
}
#[allow(dead_code)]
impl SignedStringComponents {
pub(crate) fn headers_list(&self) -> String {
let mut parts: Vec<&str> = vec!["(request-target)", "(created)", "(expires)"];
if self.digest.is_some() {
parts.push("digest");
}
if !self.extra_headers.is_empty() {
let extra_names: Vec<&str> =
self.extra_headers.iter().map(|(n, _)| n.as_str()).collect();
parts.extend(extra_names);
}
parts.join(" ")
}
pub(crate) fn build(&self) -> crate::Result<String> {
if self.method.is_empty() {
return Err(crate::Error::Validation(
"HTTP method must not be empty".into(),
));
}
if self.path.is_empty() {
return Err(crate::Error::Validation(
"request path must not be empty".into(),
));
}
let mut lines = Vec::new();
lines.push(format!(
"(request-target): {} {}",
self.method.to_lowercase(),
self.path
));
lines.push(format!("(created): {}", self.created));
lines.push(format!("(expires): {}", self.expires));
if let Some(ref digest) = self.digest {
lines.push(format!("digest: {}", digest));
}
for (name, value) in &self.extra_headers {
lines.push(format!("{name}: {value}"));
}
Ok(lines.join("\n"))
}
}
#[allow(dead_code)]
pub(crate) struct HttpSignature {
pub(crate) key_id: String,
pub(crate) algorithm: String,
pub(crate) headers: String,
pub(crate) signature: String,
}
#[allow(dead_code)]
impl HttpSignature {
pub(crate) fn to_signature_header(&self) -> String {
format!(
r#"keyId="{}",algorithm="{}",headers="{}",signature="{}""#,
self.key_id, self.algorithm, self.headers, self.signature
)
}
pub(crate) fn to_authorization_header(&self) -> String {
format!("Signature {}", self.to_signature_header())
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_signed_string_construction() {
let components = crate::auth::httpsig::SignedStringComponents {
method: "POST".to_string(),
path: "/api/v1/repos/owner/repo".to_string(),
created: 1704067200,
expires: 1704067210,
digest: Some("SHA256=abc123=".to_string()),
extra_headers: vec![],
};
let signed_string = components.build().unwrap();
let expected = [
"(request-target): post /api/v1/repos/owner/repo",
"(created): 1704067200",
"(expires): 1704067210",
"digest: SHA256=abc123=",
]
.join("\n");
assert_eq!(signed_string, expected);
}
#[test]
fn test_signed_string_without_digest() {
let components = crate::auth::httpsig::SignedStringComponents {
method: "GET".to_string(),
path: "/api/v1/user".to_string(),
created: 1704153600,
expires: 1704153610,
digest: None,
extra_headers: vec![],
};
let signed_string = components.build().unwrap();
let expected = [
"(request-target): get /api/v1/user",
"(created): 1704153600",
"(expires): 1704153610",
]
.join("\n");
assert_eq!(signed_string, expected);
}
#[test]
fn test_modern_signature_header() {
let sig = crate::auth::httpsig::HttpSignature {
key_id: "SHA256:abc123=".to_string(),
algorithm: "ed25519".to_string(),
headers: "(request-target) (created) (expires) digest".to_string(),
signature: "Zm9vYmFyYmF6".to_string(),
};
let header = sig.to_signature_header();
let expected = r#"keyId="SHA256:abc123=",algorithm="ed25519",headers="(request-target) (created) (expires) digest",signature="Zm9vYmFyYmF6""#;
assert_eq!(header, expected);
}
#[test]
fn test_legacy_authorization_header() {
let sig = crate::auth::httpsig::HttpSignature {
key_id: "SHA256:xyz789=".to_string(),
algorithm: "rsa-sha2-256".to_string(),
headers: "(request-target) (created) (expires)".to_string(),
signature: "c2lnbmF0dXJl".to_string(),
};
let header = sig.to_authorization_header();
let expected = r#"Signature keyId="SHA256:xyz789=",algorithm="rsa-sha2-256",headers="(request-target) (created) (expires)",signature="c2lnbmF0dXJl""#;
assert_eq!(header, expected);
}
#[test]
fn test_signed_string_headers_list_with_digest() {
let components = crate::auth::httpsig::SignedStringComponents {
method: "POST".to_string(),
path: "/api/v1/repos/owner/repo".to_string(),
created: 1704067200,
expires: 1704067210,
digest: Some("SHA256=abc123=".to_string()),
extra_headers: vec![],
};
let headers_list = components.headers_list();
assert_eq!(headers_list, "(request-target) (created) (expires) digest");
}
#[test]
fn test_signed_string_headers_list_without_digest() {
let components = crate::auth::httpsig::SignedStringComponents {
method: "GET".to_string(),
path: "/api/v1/user".to_string(),
created: 1704067200,
expires: 1704067210,
digest: None,
extra_headers: vec![],
};
let headers_list = components.headers_list();
assert_eq!(headers_list, "(request-target) (created) (expires)");
}
#[test]
fn test_signed_string_with_extra_headers() {
let components = crate::auth::httpsig::SignedStringComponents {
method: "POST".to_string(),
path: "/api/v1/repos/owner/repo".to_string(),
created: 1704067200,
expires: 1704067210,
digest: None,
extra_headers: vec![("x-ssh-certificate".to_string(), "certdata".to_string())],
};
let headers_list = components.headers_list();
assert_eq!(
headers_list,
"(request-target) (created) (expires) x-ssh-certificate"
);
let signed_string = components.build().unwrap();
assert!(signed_string.contains("x-ssh-certificate: certdata"));
}
}