use std::fmt::Write as _;
use base64::{engine::general_purpose::STANDARD, Engine};
use wimsey_jose::{SigningKey, VerifyingKey, SIGNATURE_LEN};
use crate::error::HttpSigError;
use crate::message::{Component, ComponentSource};
pub const ALG: &str = "ed25519";
pub const WIMSE_TAG: &str = "wimse-workload-to-workload";
pub const WIMSE_LABEL: &str = "wimse";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SignatureParams {
pub created: Option<u64>,
pub expires: Option<u64>,
pub keyid: Option<String>,
pub alg: Option<String>,
pub nonce: Option<String>,
pub tag: Option<String>,
pub wimse_aud: Option<String>,
pub wimse_sign_response: Option<bool>,
pub wimse_req_nonce: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SignedSignature {
pub signature_input: String,
pub signature: String,
}
#[derive(Debug, Clone)]
pub struct VerifiedSignature {
pub label: String,
pub components: Vec<Component>,
pub params: SignatureParams,
}
#[derive(Debug, Clone, Default)]
pub struct VerifyConfig {
pub now: Option<u64>,
pub leeway: u64,
pub label: Option<String>,
pub required_components: Vec<Component>,
pub max_age: Option<u64>,
pub wimse_profile: bool,
pub expected_audience: Option<String>,
pub wimse_response_profile: bool,
pub expected_req_nonce: Option<String>,
}
pub fn check_request_profile(params: &SignatureParams) -> Result<(), HttpSigError> {
if params.keyid.is_some() {
return Err(HttpSigError::ForbiddenParameter("keyid"));
}
if params.alg.is_some() {
return Err(HttpSigError::ForbiddenParameter("alg"));
}
if params.created.is_none() {
return Err(HttpSigError::MissingParameter("created"));
}
if params.expires.is_none() {
return Err(HttpSigError::MissingParameter("expires"));
}
if params.nonce.is_none() {
return Err(HttpSigError::MissingParameter("nonce"));
}
match params.tag.as_deref() {
None => return Err(HttpSigError::MissingParameter("tag")),
Some(tag) if tag != WIMSE_TAG => {
return Err(HttpSigError::WrongTag {
found: tag.to_owned(),
})
}
Some(_) => {}
}
if params.wimse_aud.is_none() {
return Err(HttpSigError::MissingParameter("wimse-aud"));
}
Ok(())
}
pub fn check_response_profile(
params: &SignatureParams,
response_signing_required: bool,
) -> Result<(), HttpSigError> {
if params.keyid.is_some() {
return Err(HttpSigError::ForbiddenParameter("keyid"));
}
if params.alg.is_some() {
return Err(HttpSigError::ForbiddenParameter("alg"));
}
if params.created.is_none() {
return Err(HttpSigError::MissingParameter("created"));
}
if params.expires.is_none() {
return Err(HttpSigError::MissingParameter("expires"));
}
if params.nonce.is_none() {
return Err(HttpSigError::MissingParameter("nonce"));
}
match params.tag.as_deref() {
None => return Err(HttpSigError::MissingParameter("tag")),
Some(tag) if tag != WIMSE_TAG => {
return Err(HttpSigError::WrongTag {
found: tag.to_owned(),
})
}
Some(_) => {}
}
if params.wimse_aud.is_some() {
return Err(HttpSigError::ForbiddenParameter("wimse-aud"));
}
if response_signing_required && params.wimse_req_nonce.is_none() {
return Err(HttpSigError::MissingParameter("wimse-req-nonce"));
}
Ok(())
}
#[must_use]
pub fn response_components(headers: &[(String, String)]) -> Vec<Component> {
let mut components = vec![
Component::Status,
Component::Req(Box::new(Component::Method)),
Component::Req(Box::new(Component::RequestTarget)),
];
for name in ["content-type", "content-digest", "workload-identity-token"] {
if headers.iter().any(|(n, _)| n.eq_ignore_ascii_case(name)) {
components.push(Component::header(name));
}
}
components
}
fn sf_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
if c == '\\' || c == '"' {
out.push('\\');
}
out.push(c);
}
out.push('"');
out
}
fn serialize_params_value(components: &[Component], params: &SignatureParams) -> String {
let inner = components
.iter()
.map(Component::quoted_id)
.collect::<Vec<_>>()
.join(" ");
let mut s = format!("({inner})");
if let Some(created) = params.created {
let _ = write!(s, ";created={created}");
}
if let Some(expires) = params.expires {
let _ = write!(s, ";expires={expires}");
}
if let Some(keyid) = ¶ms.keyid {
let _ = write!(s, ";keyid={}", sf_string(keyid));
}
if let Some(alg) = ¶ms.alg {
let _ = write!(s, ";alg={}", sf_string(alg));
}
if let Some(nonce) = ¶ms.nonce {
let _ = write!(s, ";nonce={}", sf_string(nonce));
}
if let Some(tag) = ¶ms.tag {
let _ = write!(s, ";tag={}", sf_string(tag));
}
if let Some(aud) = ¶ms.wimse_aud {
let _ = write!(s, ";wimse-aud={}", sf_string(aud));
}
if let Some(sign_response) = params.wimse_sign_response {
if sign_response {
s.push_str(";wimse-sign-response");
} else {
s.push_str(";wimse-sign-response=?0");
}
}
if let Some(req_nonce) = ¶ms.wimse_req_nonce {
let _ = write!(s, ";wimse-req-nonce={}", sf_string(req_nonce));
}
s
}
fn signature_base_from_params_str(
message: &impl ComponentSource,
components: &[Component],
params_value: &str,
) -> Result<String, HttpSigError> {
if params_value.contains(['\r', '\n']) {
return Err(HttpSigError::Parse(
"signature parameters contain CR or LF".to_owned(),
));
}
let mut base = String::new();
for component in components {
let value = message.component_value(component)?;
if value.contains(['\r', '\n']) {
return Err(HttpSigError::InvalidComponentValue(component.quoted_id()));
}
base.push_str(&component.quoted_id());
base.push_str(": ");
base.push_str(&value);
base.push('\n');
}
base.push_str("\"@signature-params\": ");
base.push_str(params_value);
Ok(base)
}
pub fn signature_base(
message: &impl ComponentSource,
components: &[Component],
params: &SignatureParams,
) -> Result<String, HttpSigError> {
let params_value = serialize_params_value(components, params);
signature_base_from_params_str(message, components, ¶ms_value)
}
pub fn sign(
message: &impl ComponentSource,
components: &[Component],
params: &SignatureParams,
label: &str,
signing_key: &SigningKey,
) -> Result<SignedSignature, HttpSigError> {
let params_value = serialize_params_value(components, params);
let base = signature_base_from_params_str(message, components, ¶ms_value)?;
Ok(SignedSignature {
signature_input: format!("{label}={params_value}"),
signature: format!(
"{label}=:{}:",
STANDARD.encode(signing_key.sign(base.as_bytes()))
),
})
}
fn split_member(value: &str) -> Result<(&str, &str), HttpSigError> {
let value = value.trim();
let eq = value
.find('=')
.ok_or_else(|| HttpSigError::Parse("missing `=` in dictionary member".to_owned()))?;
let label = value[..eq].trim();
if label.is_empty() {
return Err(HttpSigError::Parse("empty signature label".to_owned()));
}
Ok((label, &value[eq + 1..]))
}
fn parse_sf_string(token: &str) -> Result<String, HttpSigError> {
let inner = token
.strip_prefix('"')
.and_then(|t| t.strip_suffix('"'))
.ok_or_else(|| HttpSigError::Parse(format!("not a string: {token}")))?;
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some(next @ ('\\' | '"')) => out.push(next),
_ => return Err(HttpSigError::Parse("bad string escape".to_owned())),
}
} else {
out.push(c);
}
}
Ok(out)
}
fn find_unquoted(s: &str, target: char) -> Option<usize> {
let mut in_quotes = false;
let mut escaped = false;
for (idx, c) in s.char_indices() {
if escaped {
escaped = false;
} else if in_quotes && c == '\\' {
escaped = true;
} else if c == '"' {
in_quotes = !in_quotes;
} else if c == target && !in_quotes {
return Some(idx);
}
}
None
}
fn split_unquoted_semicolons(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut in_quotes = false;
let mut escaped = false;
for (idx, c) in s.char_indices() {
if escaped {
escaped = false;
} else if in_quotes && c == '\\' {
escaped = true;
} else if c == '"' {
in_quotes = !in_quotes;
} else if c == ';' && !in_quotes {
parts.push(&s[start..idx]);
start = idx + 1;
}
}
parts.push(&s[start..]);
parts
}
fn parse_params(rest: &str, params: &mut SignatureParams) -> Result<(), HttpSigError> {
for part in split_unquoted_semicolons(rest) {
let part = part.trim();
if part.is_empty() {
continue;
}
let Some((name, raw)) = part.split_once('=') else {
if part == "wimse-sign-response" {
params.wimse_sign_response = Some(true);
}
continue;
};
let name = name.trim();
let raw = raw.trim();
match name {
"created" => {
params.created = Some(parse_int(raw)?);
}
"expires" => {
params.expires = Some(parse_int(raw)?);
}
"keyid" => params.keyid = Some(parse_sf_string(raw)?),
"alg" => params.alg = Some(parse_sf_string(raw)?),
"nonce" => params.nonce = Some(parse_sf_string(raw)?),
"tag" => params.tag = Some(parse_sf_string(raw)?),
"wimse-aud" => params.wimse_aud = Some(parse_sf_string(raw)?),
"wimse-sign-response" => params.wimse_sign_response = Some(parse_sf_boolean(raw)?),
"wimse-req-nonce" => params.wimse_req_nonce = Some(parse_sf_string(raw)?),
_ => {}
}
}
Ok(())
}
fn parse_sf_boolean(raw: &str) -> Result<bool, HttpSigError> {
match raw {
"?1" => Ok(true),
"?0" => Ok(false),
other => Err(HttpSigError::Parse(format!("not a boolean: {other}"))),
}
}
fn parse_int(raw: &str) -> Result<u64, HttpSigError> {
raw.trim()
.parse()
.map_err(|_| HttpSigError::Parse(format!("not an integer: {raw}")))
}
fn parse_signature_input(
value: &str,
) -> Result<(String, Vec<Component>, SignatureParams, String), HttpSigError> {
let (label, rest) = split_member(value)?;
let rest = rest.trim();
if !rest.starts_with('(') {
return Err(HttpSigError::Parse(
"inner list must start with `(`".to_owned(),
));
}
let close = find_unquoted(rest, ')')
.ok_or_else(|| HttpSigError::Parse("missing `)` in inner list".to_owned()))?;
let inner = &rest[1..close];
let mut components = Vec::new();
for token in inner.split_whitespace() {
components.push(Component::from_quoted_id(token)?);
}
let mut params = SignatureParams::default();
parse_params(&rest[close + 1..], &mut params)?;
Ok((label.to_owned(), components, params, rest.to_owned()))
}
fn parse_signature(value: &str) -> Result<(String, [u8; SIGNATURE_LEN]), HttpSigError> {
let (label, rest) = split_member(value)?;
let b64 = rest
.trim()
.strip_prefix(':')
.and_then(|t| t.strip_suffix(':'))
.ok_or_else(|| HttpSigError::Parse("byte sequence must be wrapped in `:`".to_owned()))?;
let bytes = STANDARD
.decode(b64)
.map_err(|_| HttpSigError::MalformedSignature)?;
let array: [u8; SIGNATURE_LEN] = bytes
.try_into()
.map_err(|_| HttpSigError::MalformedSignature)?;
Ok((label.to_owned(), array))
}
pub fn verify(
message: &impl ComponentSource,
signature_input: &str,
signature: &str,
verifying_key: &VerifyingKey,
config: &VerifyConfig,
) -> Result<VerifiedSignature, HttpSigError> {
let (input_label, components, params, params_value) = parse_signature_input(signature_input)?;
let (sig_label, sig_bytes) = parse_signature(signature)?;
if input_label != sig_label {
return Err(HttpSigError::LabelMismatch);
}
if let Some(expected) = &config.label {
if expected != &input_label {
return Err(HttpSigError::LabelMismatch);
}
}
if config.wimse_profile {
check_request_profile(¶ms)?;
}
if config.wimse_response_profile {
check_response_profile(¶ms, config.expected_req_nonce.is_some())?;
}
if let Some(alg) = ¶ms.alg {
if alg != ALG {
return Err(HttpSigError::UnsupportedAlg { found: alg.clone() });
}
}
let base = signature_base_from_params_str(message, &components, ¶ms_value)?;
verifying_key
.verify(base.as_bytes(), &sig_bytes)
.map_err(|_| HttpSigError::InvalidSignature)?;
for required in &config.required_components {
if !components.contains(required) {
return Err(HttpSigError::MissingRequiredComponent(required.quoted_id()));
}
}
if let Some(expected) = &config.expected_audience {
if params.wimse_aud.as_ref() != Some(expected) {
return Err(HttpSigError::AudienceMismatch);
}
}
if let Some(expected) = &config.expected_req_nonce {
if params.wimse_req_nonce.as_ref() != Some(expected) {
return Err(HttpSigError::RequestNonceMismatch);
}
}
if let (Some(created), Some(expires)) = (params.created, params.expires) {
if expires < created {
return Err(HttpSigError::InvalidTimeWindow);
}
}
if config.max_age.is_some() && config.now.is_none() {
return Err(HttpSigError::TooOld);
}
if let Some(now) = config.now {
if let Some(expires) = params.expires {
if now > expires.saturating_add(config.leeway) {
return Err(HttpSigError::Expired);
}
}
if let Some(created) = params.created {
if created > now.saturating_add(config.leeway) {
return Err(HttpSigError::CreatedInFuture);
}
}
if let Some(max_age) = config.max_age {
let created = params.created.ok_or(HttpSigError::TooOld)?;
if now.saturating_sub(created) > max_age {
return Err(HttpSigError::TooOld);
}
}
}
Ok(VerifiedSignature {
label: input_label,
components,
params,
})
}
#[cfg(test)]
mod tests {
use wimsey_jose::SigningKey;
use super::{sign, signature_base, verify, SignatureParams, VerifyConfig, ALG};
use crate::error::HttpSigError;
use crate::message::{Component, HttpExchange, HttpRequest};
fn rfc_request() -> HttpRequest {
HttpRequest {
method: "POST".to_owned(),
authority: "example.com".to_owned(),
path: "/foo".to_owned(),
query: Some("param=Value&Pet=dog".to_owned()),
headers: vec![
("Host".to_owned(), "example.com".to_owned()),
("Date".to_owned(), "Tue, 20 Apr 2021 02:07:55 GMT".to_owned()),
("Content-Type".to_owned(), "application/json".to_owned()),
(
"Content-Digest".to_owned(),
"sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:".to_owned(),
),
("Content-Length".to_owned(), "18".to_owned()),
],
}
}
fn rfc_components() -> Vec<Component> {
vec![
Component::Method,
Component::Authority,
Component::Path,
Component::header("content-digest"),
Component::header("content-length"),
Component::header("content-type"),
]
}
#[test]
fn signature_base_matches_rfc_9421() {
let params = SignatureParams {
created: Some(1_618_884_473),
keyid: Some("test-key-rsa-pss".to_owned()),
..SignatureParams::default()
};
let base = signature_base(&rfc_request(), &rfc_components(), ¶ms).unwrap();
let expected = concat!(
"\"@method\": POST\n",
"\"@authority\": example.com\n",
"\"@path\": /foo\n",
"\"content-digest\": sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:\n",
"\"content-length\": 18\n",
"\"content-type\": application/json\n",
"\"@signature-params\": (\"@method\" \"@authority\" \"@path\" \"content-digest\" \"content-length\" \"content-type\");created=1618884473;keyid=\"test-key-rsa-pss\""
);
assert_eq!(base, expected);
}
fn ed25519_params() -> SignatureParams {
SignatureParams {
created: Some(1_700_000_000),
keyid: Some("issuer-key-1".to_owned()),
alg: Some(ALG.to_owned()),
..SignatureParams::default()
}
}
#[test]
fn round_trips() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let components = rfc_components();
let signed = sign(&request, &components, &ed25519_params(), "sig1", &key).unwrap();
let verified = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&VerifyConfig::default(),
)
.unwrap();
assert_eq!(verified.label, "sig1");
assert_eq!(verified.components, components);
assert_eq!(verified.params.keyid.as_deref(), Some("issuer-key-1"));
}
#[test]
fn rejects_a_tampered_request() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let mut request = rfc_request();
let components = rfc_components();
let signed = sign(&request, &components, &ed25519_params(), "sig1", &key).unwrap();
request
.headers
.push(("Content-Length".to_owned(), "19".to_owned()));
request
.headers
.retain(|(n, v)| !(n == "Content-Length" && v == "18"));
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&VerifyConfig::default(),
);
assert!(matches!(err, Err(HttpSigError::InvalidSignature)));
}
#[test]
fn rejects_the_wrong_key() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let other = SigningKey::from_ed25519_seed(&[6u8; 32]);
let request = rfc_request();
let signed = sign(&request, &rfc_components(), &ed25519_params(), "sig1", &key).unwrap();
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&other.verifying_key(),
&VerifyConfig::default(),
);
assert!(matches!(err, Err(HttpSigError::InvalidSignature)));
}
#[test]
fn rejects_a_missing_covered_header() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = HttpRequest {
headers: vec![],
..rfc_request()
};
let err = sign(&request, &rfc_components(), &ed25519_params(), "sig1", &key);
assert!(matches!(err, Err(HttpSigError::MissingComponent(_))));
}
#[test]
fn enforces_expiry() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let params = SignatureParams {
created: Some(1_700_000_000),
expires: Some(1_700_000_300),
keyid: Some("k".to_owned()),
alg: Some(ALG.to_owned()),
..SignatureParams::default()
};
let signed = sign(&request, &rfc_components(), ¶ms, "sig1", &key).unwrap();
let config = VerifyConfig {
now: Some(1_700_000_301),
..VerifyConfig::default()
};
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&config,
);
assert!(matches!(err, Err(HttpSigError::Expired)));
}
#[test]
fn rejects_a_label_mismatch() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let signed = sign(&request, &rfc_components(), &ed25519_params(), "sig1", &key).unwrap();
let config = VerifyConfig {
label: Some("other".to_owned()),
..VerifyConfig::default()
};
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&config,
);
assert!(matches!(err, Err(HttpSigError::LabelMismatch)));
}
#[test]
fn is_deterministic() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let components = rfc_components();
let a = sign(&request, &components, &ed25519_params(), "sig1", &key).unwrap();
let b = sign(&request, &components, &ed25519_params(), "sig1", &key).unwrap();
assert_eq!(a.signature_input, b.signature_input);
assert_eq!(a.signature, b.signature);
}
#[test]
fn rejects_a_missing_required_component() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let signed = sign(
&request,
&[Component::Method, Component::Path],
&ed25519_params(),
"sig1",
&key,
)
.unwrap();
let config = VerifyConfig {
required_components: vec![Component::header("content-digest")],
..VerifyConfig::default()
};
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&config,
);
assert!(matches!(
err,
Err(HttpSigError::MissingRequiredComponent(_))
));
}
#[test]
fn rejects_a_non_ed25519_alg() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let params = SignatureParams {
created: Some(1_700_000_000),
keyid: Some("k".to_owned()),
alg: Some("rsa-pss".to_owned()),
..SignatureParams::default()
};
let signed = sign(&request, &rfc_components(), ¶ms, "sig1", &key).unwrap();
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&VerifyConfig::default(),
);
assert!(matches!(err, Err(HttpSigError::UnsupportedAlg { .. })));
}
#[test]
fn rejects_crlf_in_a_covered_header() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let mut request = rfc_request();
request
.headers
.push(("X-Evil".to_owned(), "ok\n\"@path\": /evil".to_owned()));
let err = sign(
&request,
&[Component::Method, Component::header("x-evil")],
&ed25519_params(),
"sig1",
&key,
);
assert!(matches!(err, Err(HttpSigError::InvalidComponentValue(_))));
}
#[test]
fn rejects_an_inverted_time_window() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let params = SignatureParams {
created: Some(1_700_000_300),
expires: Some(1_700_000_000),
keyid: Some("k".to_owned()),
alg: Some(ALG.to_owned()),
..SignatureParams::default()
};
let signed = sign(&request, &rfc_components(), ¶ms, "sig1", &key).unwrap();
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&VerifyConfig::default(),
);
assert!(matches!(err, Err(HttpSigError::InvalidTimeWindow)));
}
#[test]
fn enforces_max_age() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let signed = sign(&request, &rfc_components(), &ed25519_params(), "sig1", &key).unwrap();
let config = VerifyConfig {
now: Some(1_700_000_400),
max_age: Some(60),
..VerifyConfig::default()
};
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&config,
);
assert!(matches!(err, Err(HttpSigError::TooOld)));
}
#[test]
fn tolerates_unknown_boolean_parameters() {
use base64::{engine::general_purpose::STANDARD, Engine};
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let params_value = "(\"@method\" \"@path\");created=1700000000;ext";
let base = format!(
"\"@method\": {}\n\"@path\": {}\n\"@signature-params\": {params_value}",
request.method, request.path,
);
let signature = STANDARD.encode(key.sign(base.as_bytes()));
let signature_input = format!("sig1={params_value}");
let signature = format!("sig1=:{signature}:");
let verified = verify(
&request,
&signature_input,
&signature,
&key.verifying_key(),
&VerifyConfig::default(),
)
.unwrap();
assert_eq!(verified.params.created, Some(1_700_000_000));
}
#[test]
fn content_digest_helper_binds_the_body() {
use crate::message::{content_digest_sha256, verify_content_digest};
let body = br#"{"amount":100}"#;
let header = content_digest_sha256(body);
assert!(verify_content_digest(&header, body));
assert!(!verify_content_digest(&header, b"tampered"));
}
#[test]
fn round_trips_params_with_quoted_delimiters() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let params = SignatureParams {
created: Some(1_700_000_000),
keyid: Some("weird;key)with\"quote".to_owned()),
alg: Some(ALG.to_owned()),
..SignatureParams::default()
};
let signed = sign(&request, &rfc_components(), ¶ms, "sig1", &key).unwrap();
let verified = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&VerifyConfig::default(),
)
.unwrap();
assert_eq!(
verified.params.keyid.as_deref(),
Some("weird;key)with\"quote")
);
}
#[test]
fn rejects_crlf_in_signature_params() {
use base64::{engine::general_purpose::STANDARD, Engine};
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let signature_input = "sig1=(\"@method\")\n;created=1700000000";
let signature = format!("sig1=:{}:", STANDARD.encode([0u8; 64]));
let err = verify(
&request,
signature_input,
&signature,
&key.verifying_key(),
&VerifyConfig::default(),
);
assert!(matches!(err, Err(HttpSigError::Parse(_))));
}
use base64::{engine::general_purpose::STANDARD, Engine};
use super::{check_request_profile, WIMSE_TAG};
fn wimse_params() -> SignatureParams {
SignatureParams {
created: Some(1_700_000_000),
expires: Some(1_700_000_300),
nonce: Some("abcd1111".to_owned()),
tag: Some(WIMSE_TAG.to_owned()),
wimse_aud: Some("https://svcb.example.com/gimme-ice-cream".to_owned()),
..SignatureParams::default()
}
}
fn wimse_config() -> VerifyConfig {
VerifyConfig {
wimse_profile: true,
..VerifyConfig::default()
}
}
fn sign_with(params: &SignatureParams) -> (SigningKey, HttpRequest, super::SignedSignature) {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let signed = sign(&request, &rfc_components(), params, "wimse", &key).unwrap();
(key, request, signed)
}
#[test]
fn accepts_a_profile_conforming_signature() {
let (key, request, signed) = sign_with(&wimse_params());
let verified = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&wimse_config(),
)
.unwrap();
assert_eq!(verified.params.tag.as_deref(), Some(WIMSE_TAG));
}
#[test]
fn profile_rejects_keyid_and_alg() {
for (label, params) in [
(
"keyid",
SignatureParams {
keyid: Some("k".to_owned()),
..wimse_params()
},
),
(
"alg",
SignatureParams {
alg: Some(ALG.to_owned()),
..wimse_params()
},
),
] {
let (key, request, signed) = sign_with(¶ms);
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&wimse_config(),
);
assert!(
matches!(err, Err(HttpSigError::ForbiddenParameter(p)) if p == label),
"expected `{label}` to be rejected, got {err:?}"
);
}
}
#[test]
fn profile_requires_the_mandatory_parameters() {
let cases: [(&str, SignatureParams); 5] = [
(
"created",
SignatureParams {
created: None,
..wimse_params()
},
),
(
"expires",
SignatureParams {
expires: None,
..wimse_params()
},
),
(
"nonce",
SignatureParams {
nonce: None,
..wimse_params()
},
),
(
"tag",
SignatureParams {
tag: None,
..wimse_params()
},
),
(
"wimse-aud",
SignatureParams {
wimse_aud: None,
..wimse_params()
},
),
];
for (name, params) in cases {
let err = check_request_profile(¶ms);
assert!(
matches!(err, Err(HttpSigError::MissingParameter(p)) if p == name),
"expected `{name}` to be required, got {err:?}"
);
}
}
#[test]
fn profile_rejects_a_foreign_tag() {
let params = SignatureParams {
tag: Some("something-else".to_owned()),
..wimse_params()
};
let (key, request, signed) = sign_with(¶ms);
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&wimse_config(),
);
assert!(matches!(err, Err(HttpSigError::WrongTag { .. })));
}
#[test]
fn rejects_a_signature_minted_for_another_audience() {
let (key, request, signed) = sign_with(&wimse_params());
let config = VerifyConfig {
expected_audience: Some("https://svcc.example.com/other".to_owned()),
..wimse_config()
};
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&config,
);
assert!(matches!(err, Err(HttpSigError::AudienceMismatch)));
}
#[test]
fn reports_a_forgery_as_invalid_regardless_of_audience() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let (_, _, signed) = sign_with(&wimse_params());
let forged = format!("wimse=:{}:", STANDARD.encode([0u8; 64]));
for audience in [
"https://svcb.example.com/gimme-ice-cream",
"https://wrong.example/inbox",
] {
let config = VerifyConfig {
expected_audience: Some(audience.to_owned()),
..wimse_config()
};
let err = verify(
&request,
&signed.signature_input,
&forged,
&key.verifying_key(),
&config,
);
assert!(
matches!(err, Err(HttpSigError::InvalidSignature)),
"expected InvalidSignature for audience {audience}, got {err:?}"
);
}
}
#[test]
fn accepts_the_matching_audience() {
let (key, request, signed) = sign_with(&wimse_params());
let config = VerifyConfig {
expected_audience: Some("https://svcb.example.com/gimme-ice-cream".to_owned()),
..wimse_config()
};
assert!(verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&config,
)
.is_ok());
}
#[test]
fn serializes_sign_response_as_a_bare_boolean() {
let params = SignatureParams {
wimse_sign_response: Some(true),
..wimse_params()
};
let (key, request, signed) = sign_with(¶ms);
assert!(signed.signature_input.ends_with(";wimse-sign-response"));
let verified = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&wimse_config(),
)
.unwrap();
assert_eq!(verified.params.wimse_sign_response, Some(true));
}
#[test]
fn round_trips_an_explicit_false_sign_response() {
let params = SignatureParams {
wimse_sign_response: Some(false),
..wimse_params()
};
let (key, request, signed) = sign_with(¶ms);
assert!(signed.signature_input.ends_with(";wimse-sign-response=?0"));
let verified = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&wimse_config(),
)
.unwrap();
assert_eq!(verified.params.wimse_sign_response, Some(false));
}
#[test]
fn round_trips_the_response_nonce_binding() {
let params = SignatureParams {
wimse_req_nonce: Some("abcd1111".to_owned()),
..wimse_params()
};
let (key, request, signed) = sign_with(¶ms);
let verified = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&wimse_config(),
)
.unwrap();
assert_eq!(verified.params.wimse_req_nonce.as_deref(), Some("abcd1111"));
}
#[test]
fn profile_is_off_by_default() {
let (key, request, signed) = sign_with(&ed25519_params());
assert!(verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&VerifyConfig::default(),
)
.is_ok());
}
use crate::message::HttpResponse;
fn rfc_response() -> HttpResponse {
HttpResponse {
status: 200,
headers: vec![
("Content-Type".to_owned(), "application/json".to_owned()),
(
"Workload-Identity-Token".to_owned(),
"eyJ0eXAi.wit.value".to_owned(),
),
],
}
}
fn response_params() -> SignatureParams {
SignatureParams {
created: Some(1_700_000_000),
expires: Some(1_700_000_300),
nonce: Some("resp-2222".to_owned()),
tag: Some(WIMSE_TAG.to_owned()),
wimse_req_nonce: Some("abcd1111".to_owned()),
..SignatureParams::default()
}
}
fn response_config() -> VerifyConfig {
VerifyConfig {
wimse_response_profile: true,
expected_req_nonce: Some("abcd1111".to_owned()),
..VerifyConfig::default()
}
}
#[test]
fn signs_and_verifies_a_response() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let response = rfc_response();
let exchange = HttpExchange {
response: &response,
request: &request,
};
let components = super::response_components(&response.headers);
let signed = sign(&exchange, &components, &response_params(), "wimse", &key).unwrap();
assert!(signed
.signature_input
.contains(r#""@status" "@method";req "@request-target";req"#));
let verified = verify(
&exchange,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&response_config(),
)
.unwrap();
assert_eq!(verified.params.wimse_req_nonce.as_deref(), Some("abcd1111"));
}
#[test]
fn a_response_cannot_be_lifted_onto_another_request() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let response = rfc_response();
let signed = sign(
&HttpExchange {
response: &response,
request: &request,
},
&super::response_components(&response.headers),
&response_params(),
"wimse",
&key,
)
.unwrap();
let other_request = HttpRequest {
path: "/admin".to_owned(),
..rfc_request()
};
let err = verify(
&HttpExchange {
response: &response,
request: &other_request,
},
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&response_config(),
);
assert!(matches!(err, Err(HttpSigError::InvalidSignature)));
}
#[test]
fn rejects_a_response_answering_a_different_request() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let response = rfc_response();
let exchange = HttpExchange {
response: &response,
request: &request,
};
let signed = sign(
&exchange,
&super::response_components(&response.headers),
&response_params(),
"wimse",
&key,
)
.unwrap();
let config = VerifyConfig {
expected_req_nonce: Some("some-other-nonce".to_owned()),
..response_config()
};
let err = verify(
&exchange,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&config,
);
assert!(matches!(err, Err(HttpSigError::RequestNonceMismatch)));
}
#[test]
fn response_profile_requires_the_returned_nonce() {
let params = SignatureParams {
wimse_req_nonce: None,
..response_params()
};
assert!(matches!(
super::check_response_profile(¶ms, true),
Err(HttpSigError::MissingParameter("wimse-req-nonce"))
));
assert!(super::check_response_profile(¶ms, false).is_ok());
}
#[test]
fn response_profile_forbids_the_request_audience() {
let params = SignatureParams {
wimse_aud: Some("https://svcb.example.com/x".to_owned()),
..response_params()
};
assert!(matches!(
super::check_response_profile(¶ms, true),
Err(HttpSigError::ForbiddenParameter("wimse-aud"))
));
}
#[test]
fn a_request_has_no_status_component() {
let err = rfc_request().component_value(&Component::Status);
assert!(matches!(err, Err(HttpSigError::UnsupportedComponent(_))));
}
#[test]
fn rejects_max_age_without_now() {
let key = SigningKey::from_ed25519_seed(&[5u8; 32]);
let request = rfc_request();
let signed = sign(&request, &rfc_components(), &ed25519_params(), "sig1", &key).unwrap();
let config = VerifyConfig {
max_age: Some(60),
..VerifyConfig::default()
};
let err = verify(
&request,
&signed.signature_input,
&signed.signature,
&key.verifying_key(),
&config,
);
assert!(matches!(err, Err(HttpSigError::TooOld)));
}
}