Skip to main content

aauth_protocol/
httpsig.rs

1//! RFC 9421 HTTP Message Signatures under the AAuth profile (AAuth §HTTP
2//! Message Signatures), with the signer's key named in `Signature-Key`
3//! (draft-hardt-httpbis-signature-key).
4//!
5//! [`sign`] covers `@method`, `@authority`, `@path`, `@query` when there is
6//! one, `signature-key`, and for a request with a body `content-digest` and
7//! `content-type`. [`verify`] checks a signature and hands back the key that
8//! made it; a token in a `jwt` scheme is decoded but NOT verified — that is
9//! [`crate::token::verify_token`]'s job, against its issuer's keys.
10
11use base64::engine::general_purpose::STANDARD;
12use base64::Engine;
13use http::HeaderMap;
14use sha2::{Digest, Sha256};
15use url::Url;
16
17use crate::discovery::KeyResolver;
18use crate::error::{Error, SignatureError};
19use crate::identifier::authority;
20use crate::jwk::{Algorithm, Jwk, KeyProblem};
21use crate::jwt::{self, Decoded};
22use crate::keys::SigningKey;
23use crate::sf::{self, Bare, Item, Member};
24
25/// The label this crate signs under.
26pub const LABEL: &str = "sig";
27
28/// Request headers a signer sets, which a proxy signing on someone's behalf
29/// should strip from what it was handed.
30pub const SIGNATURE_HEADERS: &[&str] = &[
31    "signature",
32    "signature-input",
33    "signature-key",
34    "content-digest",
35];
36
37/// How the verifier finds the signer's key: the `Signature-Key` scheme.
38#[derive(Clone, Debug, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum SignatureKey {
41    /// A JWT whose `cnf.jwk` is the key: agent, person and auth tokens.
42    Jwt(String),
43    /// A server's published key: `{id}/.well-known/{dwk}` names a `jwks_uri`.
44    JwksUri {
45        /// The server's identifier.
46        id: String,
47        /// Its metadata document's name.
48        dwk: String,
49        /// The key's `kid` in its JWKS.
50        kid: String,
51    },
52    /// The key itself, inline: a pseudonymous agent.
53    Hwk,
54}
55
56impl SignatureKey {
57    fn header_value(&self, key: &SigningKey) -> Result<String, Error> {
58        let quoted = |v: &str| {
59            if v.contains(['"', '\\']) || !v.is_ascii() {
60                Err(Error::Invalid(
61                    "value cannot travel in Signature-Key".into(),
62                ))
63            } else {
64                Ok(sf::quote(v))
65            }
66        };
67        Ok(match self {
68            SignatureKey::Jwt(token) => format!("{LABEL}=jwt;jwt={}", quoted(token)?),
69            SignatureKey::JwksUri { id, dwk, kid } => format!(
70                "{LABEL}=jwks_uri;id={};dwk={};kid={}",
71                quoted(id)?,
72                quoted(dwk)?,
73                quoted(kid)?
74            ),
75            SignatureKey::Hwk => {
76                let jwk = key.public_jwk();
77                format!(
78                    "{LABEL}=hwk;kty={};crv={};x={};alg={}",
79                    quoted(&jwk.kty)?,
80                    quoted(jwk.crv.as_deref().unwrap_or_default())?,
81                    quoted(jwk.x.as_deref().unwrap_or_default())?,
82                    quoted(jwk.alg.as_deref().unwrap_or_default())?
83                )
84            }
85        })
86    }
87}
88
89/// A request to sign, as its recipient will see it.
90#[derive(Clone, Copy, Debug)]
91pub struct Outgoing<'a> {
92    /// The method, in any case.
93    pub method: &'a str,
94    /// Absolute: its authority, path and query are covered.
95    pub url: &'a str,
96    /// Defaults to `application/json` for a request with a body.
97    pub content_type: Option<&'a str>,
98    /// The body exactly as it will be sent, when there is one.
99    pub body: Option<&'a [u8]>,
100    /// Seconds since the epoch.
101    pub created: i64,
102}
103
104/// `sha-256=:…:`, the RFC 9530 `Content-Digest` of `body`.
105pub fn content_digest(body: &[u8]) -> String {
106    format!("sha-256=:{}:", STANDARD.encode(Sha256::digest(body)))
107}
108
109/// The headers that sign `request` with `key`, presenting `signature_key`.
110pub fn sign(
111    key: &SigningKey,
112    signature_key: &SignatureKey,
113    request: &Outgoing<'_>,
114) -> Result<Vec<(String, String)>, Error> {
115    let url = Url::parse(request.url).map_err(|e| Error::Url(format!("{}: {e}", request.url)))?;
116    let authority = authority(&url).ok_or_else(|| Error::Url("url has no host".into()))?;
117    let signature_key = signature_key.header_value(key)?;
118
119    let mut covered: Vec<(&str, String)> = vec![
120        ("@method", request.method.to_ascii_uppercase()),
121        ("@authority", authority),
122        ("@path", url.path().to_string()),
123    ];
124    if let Some(query) = url.query() {
125        covered.push(("@query", format!("?{query}")));
126    }
127    covered.push(("signature-key", signature_key.clone()));
128    let mut headers = Vec::new();
129    if let Some(body) = request.body.filter(|b| !b.is_empty()) {
130        let digest = content_digest(body);
131        let content_type = request
132            .content_type
133            .unwrap_or("application/json")
134            .to_string();
135        covered.push(("content-digest", digest.clone()));
136        covered.push(("content-type", content_type.clone()));
137        headers.push(("content-digest".to_string(), digest));
138        headers.push(("content-type".to_string(), content_type));
139    }
140
141    let components: Vec<String> = covered
142        .iter()
143        .map(|(name, _)| format!("\"{name}\""))
144        .collect();
145    let params = format!("({});created={}", components.join(" "), request.created);
146    let mut base: Vec<String> = covered
147        .iter()
148        .map(|(name, value)| format!("\"{name}\": {value}"))
149        .collect();
150    base.push(format!("\"@signature-params\": {params}"));
151    let signature = key.sign(base.join("\n").as_bytes());
152
153    headers.push(("signature-key".to_string(), signature_key));
154    headers.push(("signature-input".to_string(), format!("{LABEL}={params}")));
155    headers.push((
156        "signature".to_string(),
157        format!("{LABEL}=:{}:", STANDARD.encode(signature)),
158    ));
159    Ok(headers)
160}
161
162/// The headers [`sign`] returns, as a [`HeaderMap`] to send.
163pub fn header_map(headers: &[(String, String)]) -> Result<HeaderMap, Error> {
164    let mut map = HeaderMap::new();
165    for (name, value) in headers {
166        map.insert(
167            http::HeaderName::from_bytes(name.as_bytes())
168                .map_err(|e| Error::Invalid(e.to_string()))?,
169            http::HeaderValue::from_str(value).map_err(|e| Error::Invalid(e.to_string()))?,
170        );
171    }
172    Ok(map)
173}
174
175/// `sign` presenting `token` under the `jwt` scheme: how an agent signs.
176pub fn sign_jwt(
177    key: &SigningKey,
178    token: &str,
179    request: &Outgoing<'_>,
180) -> Result<Vec<(String, String)>, Error> {
181    sign(key, &SignatureKey::Jwt(token.to_string()), request)
182}
183
184/// A signed request as it arrived.
185#[derive(Clone, Copy, Debug)]
186pub struct Incoming<'a> {
187    /// The method, in any case.
188    pub method: &'a str,
189    /// The authority the signer addressed: this server's own, whatever proxy
190    /// or rewrite delivered the request (AAuth §Canonical Authority).
191    pub authority: &'a str,
192    /// The path, without its query.
193    pub path: &'a str,
194    /// Without its leading `?`.
195    pub query: Option<&'a str>,
196    /// Every request header.
197    pub headers: &'a HeaderMap,
198    /// The body as received, when there is one.
199    pub body: Option<&'a [u8]>,
200}
201
202/// How [`verify`] judges a signature. The default is now, a 60-second
203/// window either way, and Ed25519 or ES256.
204#[derive(Clone, Debug)]
205pub struct VerifyOptions {
206    /// Seconds since the epoch.
207    pub now: i64,
208    /// How far `created` may be from `now`, either way.
209    pub max_clock_skew: i64,
210    /// A request with a body must cover `content-digest`: required of a
211    /// Person Server or Access Server by the profile.
212    pub require_content_digest: bool,
213    /// The algorithms accepted; any other is `unsupported_algorithm`.
214    pub algorithms: Vec<Algorithm>,
215}
216
217impl Default for VerifyOptions {
218    fn default() -> Self {
219        Self {
220            now: crate::now_unix(),
221            max_clock_skew: 60,
222            require_content_digest: false,
223            algorithms: vec![Algorithm::Ed25519, Algorithm::Es256],
224        }
225    }
226}
227
228/// What signed a request.
229#[derive(Clone, Debug)]
230#[non_exhaustive]
231pub enum Signer {
232    /// The token the signer presented, decoded and not yet verified.
233    Jwt {
234        /// The token as presented.
235        raw: String,
236        /// The token, split and decoded.
237        decoded: Box<Decoded>,
238    },
239    /// A server's published key.
240    JwksUri {
241        /// The server's identifier.
242        id: String,
243        /// Its metadata document's name.
244        dwk: String,
245        /// The key's `kid` in its JWKS.
246        kid: String,
247    },
248    /// A key carried inline.
249    Hwk,
250}
251
252/// A signature that verified.
253#[derive(Clone, Debug)]
254#[non_exhaustive]
255pub struct Verified {
256    /// The label it was made under, usually `sig`.
257    pub label: String,
258    /// How the signer's key was named.
259    pub signer: Signer,
260    /// The key that made it.
261    pub public_key: Jwk,
262    /// RFC 7638 thumbprint of `public_key`.
263    pub thumbprint: String,
264    /// When the signer says it signed, in seconds since the epoch.
265    pub created: i64,
266}
267
268impl Verified {
269    /// The presented JWT, for the `jwt` scheme.
270    pub fn jwt(&self) -> Option<&str> {
271        match &self.signer {
272            Signer::Jwt { raw, .. } => Some(raw),
273            _ => None,
274        }
275    }
276}
277
278const REQUIRED_COMPONENTS: &[&str] = &["@method", "@authority", "@path", "signature-key"];
279
280fn invalid_input(detail: impl Into<String>) -> SignatureError {
281    SignatureError::new("invalid_input", detail)
282}
283
284/// A header's values joined as RFC 9421 §2.1 prescribes. A value that is
285/// not visible ASCII makes the whole header unreadable, rather than
286/// silently dropping that value from what is covered.
287fn header(headers: &HeaderMap, name: &str) -> Option<String> {
288    let values: Option<Vec<&str>> = headers
289        .get_all(name)
290        .iter()
291        .map(|v| v.to_str().ok())
292        .collect();
293    values.filter(|v| !v.is_empty()).map(|v| v.join(", "))
294}
295
296fn text_params(item: &Item) -> Result<Vec<(String, String)>, SignatureError> {
297    item.params
298        .iter()
299        .map(|(k, v)| {
300            v.as_text()
301                .map(|t| (k.clone(), t.to_string()))
302                .ok_or_else(|| {
303                    SignatureError::new(
304                        "invalid_key",
305                        format!("Signature-Key {k} must be a String or Token"),
306                    )
307                })
308        })
309        .collect()
310}
311
312/// Verify `request`'s signature. `resolver` is consulted only for the
313/// `jwks_uri` scheme.
314pub async fn verify(
315    request: &Incoming<'_>,
316    options: &VerifyOptions,
317    resolver: &dyn KeyResolver,
318) -> Result<Verified, SignatureError> {
319    let invalid = |detail: &str| SignatureError::new("invalid_signature", detail);
320    let raw_key =
321        header(request.headers, "signature-key").ok_or_else(|| invalid("missing Signature-Key"))?;
322    let key_dict = sf::parse_dictionary(&raw_key)
323        .map_err(|e| SignatureError::new("invalid_key", format!("Signature-Key: {e}")))?;
324    let [(label, Member::Item(key_item))] = key_dict.as_slice() else {
325        return Err(SignatureError::new(
326            "invalid_key",
327            "Signature-Key must have exactly one member, an item",
328        ));
329    };
330    let Bare::Token(scheme) = &key_item.bare else {
331        return Err(SignatureError::new(
332            "invalid_key",
333            "Signature-Key scheme must be a token",
334        ));
335    };
336    let params = text_params(key_item)?;
337    let param = |name: &str| {
338        params
339            .iter()
340            .find(|(k, _)| k == name)
341            .map(|(_, v)| v.clone())
342    };
343
344    let raw_input = header(request.headers, "signature-input")
345        .ok_or_else(|| invalid("missing Signature-Input"))?;
346    let inputs =
347        sf::parse_dictionary(&raw_input).map_err(|e| invalid(&format!("Signature-Input: {e}")))?;
348    let Some((_, Member::InnerList(component_items, sig_params))) =
349        inputs.iter().find(|(l, _)| l == label)
350    else {
351        return Err(invalid("no Signature-Input for the Signature-Key label"));
352    };
353    let mut components = Vec::new();
354    for item in component_items {
355        match (&item.bare, item.params.is_empty()) {
356            (Bare::String(name), true) if components.contains(name) => {
357                return Err(invalid_input(format!("{name} is covered twice")))
358            }
359            (Bare::String(name), true) => components.push(name.clone()),
360            _ => return Err(invalid_input("only plain covered components are supported")),
361        }
362    }
363    if let Some(missing) = REQUIRED_COMPONENTS
364        .iter()
365        .find(|c| !components.iter().any(|n| n == *c))
366    {
367        return Err(invalid_input(format!("{missing} must be covered")));
368    }
369    let created = sig_params
370        .iter()
371        .find_map(|(k, v)| match (k.as_str(), v) {
372            ("created", Bare::Integer(i)) => Some(*i),
373            _ => None,
374        })
375        .ok_or_else(|| invalid("Signature-Input has no created"))?;
376    if created > options.now + options.max_clock_skew {
377        return Err(SignatureError::new(
378            "clock_skew",
379            format!(
380                "created is {}s ahead of this verifier's clock",
381                created - options.now
382            ),
383        ));
384    }
385    if options.now - created > options.max_clock_skew {
386        return Err(invalid("signature is stale"));
387    }
388    if let Some(expires) = sig_params.iter().find_map(|(k, v)| match (k.as_str(), v) {
389        ("expires", Bare::Integer(i)) => Some(*i),
390        _ => None,
391    }) {
392        if expires < options.now {
393            return Err(invalid("signature has expired"));
394        }
395    }
396
397    let (signer, public_key) = match scheme.as_str() {
398        "jwt" => {
399            let raw = param("jwt")
400                .ok_or_else(|| SignatureError::new("invalid_key", "jwt scheme missing jwt"))?;
401            let decoded =
402                jwt::decode(&raw).map_err(|e| SignatureError::new("invalid_jwt", e.to_string()))?;
403            let cnf = decoded
404                .claims
405                .get("cnf")
406                .and_then(|c| c.get("jwk"))
407                .cloned()
408                .ok_or_else(|| SignatureError::new("invalid_jwt", "JWT missing cnf.jwk"))?;
409            if !decoded.claims.get("exp").is_some_and(|e| e.is_number()) {
410                return Err(SignatureError::new("invalid_jwt", "JWT missing exp"));
411            }
412            let jwk: Jwk = serde_json::from_value(cnf)
413                .map_err(|e| SignatureError::new("invalid_key", format!("cnf.jwk: {e}")))?;
414            (
415                Signer::Jwt {
416                    raw,
417                    decoded: Box::new(decoded),
418                },
419                jwk,
420            )
421        }
422        "jwks_uri" => {
423            let (Some(id), Some(dwk), Some(kid)) = (param("id"), param("dwk"), param("kid")) else {
424                return Err(SignatureError::new(
425                    "invalid_key",
426                    "jwks_uri scheme needs id, dwk and kid",
427                ));
428            };
429            // Checked before anything is fetched: both come from whoever sent
430            // the request.
431            if !crate::identifier::is_server_identifier(&id)
432                || !crate::metadata::dwk::is_known(&dwk)
433            {
434                return Err(SignatureError::new(
435                    "invalid_key",
436                    "jwks_uri needs a server identifier and a known dwk",
437                ));
438            }
439            let jwks = resolver
440                .jwks(&id, &dwk)
441                .await
442                .map_err(|e| SignatureError::new("unknown_key", e.to_string()))?;
443            let jwk = jwks
444                .keys
445                .into_iter()
446                .find(|k| k.kid.as_deref() == Some(kid.as_str()))
447                .ok_or_else(|| {
448                    SignatureError::new("unknown_key", format!("no key {kid:?} for {id}"))
449                })?;
450            (Signer::JwksUri { id, dwk, kid }, jwk)
451        }
452        "hwk" => {
453            if param("kid").is_some() {
454                return Err(SignatureError::new(
455                    "invalid_key",
456                    "hwk must not carry a kid",
457                ));
458            }
459            let jwk = Jwk {
460                kty: param("kty").unwrap_or_default(),
461                crv: param("crv"),
462                x: param("x"),
463                y: param("y"),
464                n: param("n"),
465                e: param("e"),
466                alg: param("alg"),
467                ..Default::default()
468            };
469            (Signer::Hwk, jwk)
470        }
471        other => {
472            return Err(SignatureError::new(
473                "unsupported_scheme",
474                format!("unsupported Signature-Key scheme {other}"),
475            ))
476        }
477    };
478    let algorithm = public_key.algorithm().map_err(|problem| match problem {
479        KeyProblem::Invalid(d) => SignatureError::new("invalid_key", d),
480        KeyProblem::Unsupported(d) => SignatureError::new("unsupported_algorithm", d),
481    })?;
482    if !options.algorithms.contains(&algorithm) {
483        return Err(SignatureError::new(
484            "unsupported_algorithm",
485            format!("{} is not accepted here", algorithm.as_str()),
486        ));
487    }
488    // An `alg` parameter, when the signer sends one, must name the key's
489    // algorithm in RFC 9421's registry; the key's own `alg` decides either way.
490    if let Some(declared) = sig_params
491        .iter()
492        .find_map(|(k, v)| (k == "alg").then_some(v))
493    {
494        let expected = match algorithm {
495            Algorithm::Ed25519 => "ed25519",
496            Algorithm::Es256 => "ecdsa-p256-sha256",
497        };
498        if declared.as_text() != Some(expected) {
499            return Err(invalid("the alg parameter disagrees with the key"));
500        }
501    }
502
503    let body = request.body.filter(|b| !b.is_empty());
504    let covers_digest = components.iter().any(|c| c == "content-digest");
505    if body.is_some() && options.require_content_digest && !covers_digest {
506        return Err(invalid_input(
507            "content-digest must be covered on a request with a body",
508        ));
509    }
510    if covers_digest {
511        let sent = header(request.headers, "content-digest")
512            .ok_or_else(|| invalid_input("content-digest is covered but absent"))?;
513        if sent != content_digest(body.unwrap_or_default()) {
514            return Err(invalid_input("content-digest does not match the body"));
515        }
516    }
517
518    let mut base = Vec::new();
519    for component in &components {
520        let value = match component.as_str() {
521            "@method" => request.method.to_ascii_uppercase(),
522            "@authority" => request.authority.to_ascii_lowercase(),
523            "@path" => request.path.to_string(),
524            "@query" => format!("?{}", request.query.unwrap_or_default()),
525            "@scheme" => "https".to_string(),
526            "@target-uri" => match request.query {
527                Some(q) => format!("https://{}{}?{q}", request.authority, request.path),
528                None => format!("https://{}{}", request.authority, request.path),
529            },
530            "@request-target" => match request.query {
531                Some(q) => format!("{}?{q}", request.path),
532                None => request.path.to_string(),
533            },
534            name if name.starts_with('@') => {
535                return Err(invalid_input(format!(
536                    "unsupported derived component {name}"
537                )))
538            }
539            name => header(request.headers, name)
540                .map(|v| v.trim().to_string())
541                .ok_or_else(|| invalid_input(format!("covered header {name} is absent")))?,
542        };
543        base.push(format!("\"{component}\": {value}"));
544    }
545    base.push(format!(
546        "\"@signature-params\": {}",
547        sf::serialize_inner_list(component_items, sig_params)
548    ));
549
550    let raw_signature =
551        header(request.headers, "signature").ok_or_else(|| invalid("missing Signature"))?;
552    let signatures =
553        sf::parse_dictionary(&raw_signature).map_err(|e| invalid(&format!("Signature: {e}")))?;
554    let Some((
555        _,
556        Member::Item(Item {
557            bare: Bare::Bytes(signature),
558            ..
559        }),
560    )) = signatures.iter().find(|(l, _)| l == label)
561    else {
562        return Err(invalid("no Signature for the Signature-Key label"));
563    };
564    if !public_key.verify(algorithm, base.join("\n").as_bytes(), signature) {
565        return Err(invalid("signature does not verify"));
566    }
567    let thumbprint = public_key
568        .thumbprint()
569        .map_err(|e| SignatureError::new("invalid_key", e.to_string()))?;
570    Ok(Verified {
571        label: label.clone(),
572        signer,
573        public_key,
574        thumbprint,
575        created,
576    })
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use crate::discovery::NoKeys;
583    use http::{HeaderName, HeaderValue};
584    use serde_json::json;
585
586    fn agent_token(session: &SigningKey) -> String {
587        let header = json!({ "typ": "aa-agent+jwt", "kid": "ap-1" });
588        jwt::encode(
589            &SigningKey::generate(),
590            header.as_object().unwrap().clone(),
591            &json!({
592                "iss": "https://agent.example",
593                "dwk": "aauth-agent.json",
594                "sub": "aauth:travel@agent.example",
595                "iat": 1_791_000_000,
596                "exp": 1_791_003_600,
597                "cnf": { "jwk": session.public_jwk() },
598            }),
599        )
600    }
601
602    fn header_map(set: &[(String, String)]) -> HeaderMap {
603        let mut headers = HeaderMap::new();
604        for (k, v) in set {
605            headers.insert(
606                HeaderName::from_bytes(k.as_bytes()).unwrap(),
607                HeaderValue::from_str(v).unwrap(),
608            );
609        }
610        headers
611    }
612
613    fn get<'a>(set: &'a [(String, String)], name: &str) -> &'a str {
614        &set.iter().find(|(k, _)| k == name).unwrap().1
615    }
616
617    #[test]
618    fn a_signed_post_covers_its_body_in_the_signature_base() {
619        let key = SigningKey::generate();
620        let token = agent_token(&key);
621        let body = br#"{"offer_id":"ht-syd-qt"}"#;
622        let set = sign_jwt(
623            &key,
624            &token,
625            &Outgoing {
626                method: "post",
627                url: "https://API.booking.example/v1/reserve?x=1",
628                content_type: None,
629                body: Some(body),
630                created: 1_791_000_100,
631            },
632        )
633        .unwrap();
634        let params = r#"("@method" "@authority" "@path" "@query" "signature-key" "content-digest" "content-type");created=1791000100"#;
635        assert_eq!(get(&set, "signature-input"), format!("sig={params}"));
636        let digest = content_digest(body);
637        assert_eq!(get(&set, "content-digest"), digest);
638        let base = format!(
639            "\"@method\": POST\n\"@authority\": api.booking.example\n\"@path\": /v1/reserve\n\"@query\": ?x=1\n\"signature-key\": sig=jwt;jwt=\"{token}\"\n\"content-digest\": {digest}\n\"content-type\": application/json\n\"@signature-params\": {params}"
640        );
641        let raw = get(&set, "signature")
642            .strip_prefix("sig=:")
643            .unwrap()
644            .strip_suffix(':')
645            .unwrap();
646        let signature = STANDARD.decode(raw).unwrap();
647        assert!(key
648            .public_jwk()
649            .verify(Algorithm::Ed25519, base.as_bytes(), &signature));
650    }
651
652    async fn round_trip(
653        set: &[(String, String)],
654        method: &str,
655        path: &str,
656        query: Option<&str>,
657        body: Option<&[u8]>,
658        now: i64,
659    ) -> Result<Verified, SignatureError> {
660        let headers = header_map(set);
661        verify(
662            &Incoming {
663                method,
664                authority: "api.booking.example",
665                path,
666                query,
667                headers: &headers,
668                body,
669            },
670            &VerifyOptions {
671                now,
672                require_content_digest: true,
673                ..Default::default()
674            },
675            &NoKeys,
676        )
677        .await
678    }
679
680    #[tokio::test]
681    async fn what_is_signed_here_verifies_here() {
682        let key = SigningKey::generate();
683        let token = agent_token(&key);
684        let body = br#"{"a":1}"#;
685        let set = sign_jwt(
686            &key,
687            &token,
688            &Outgoing {
689                method: "POST",
690                url: "https://api.booking.example/v1/reserve?x=1",
691                content_type: None,
692                body: Some(body),
693                created: 1_000,
694            },
695        )
696        .unwrap();
697        let verified = round_trip(&set, "POST", "/v1/reserve", Some("x=1"), Some(body), 1_010)
698            .await
699            .unwrap();
700        assert_eq!(verified.thumbprint, key.thumbprint());
701        assert_eq!(verified.jwt(), Some(token.as_str()));
702
703        let tampered = round_trip(
704            &set,
705            "POST",
706            "/v1/reserve",
707            Some("x=1"),
708            Some(br#"{"a":2}"#),
709            1_010,
710        )
711        .await;
712        assert_eq!(tampered.unwrap_err().code, "invalid_input");
713        let moved = round_trip(&set, "POST", "/v1/cancel", Some("x=1"), Some(body), 1_010).await;
714        assert_eq!(moved.unwrap_err().code, "invalid_signature");
715        let stale = round_trip(&set, "POST", "/v1/reserve", Some("x=1"), Some(body), 2_000).await;
716        assert_eq!(stale.unwrap_err().code, "invalid_signature");
717        let early = round_trip(&set, "POST", "/v1/reserve", Some("x=1"), Some(body), 0).await;
718        assert_eq!(early.unwrap_err().code, "clock_skew");
719    }
720
721    #[tokio::test]
722    async fn an_unsigned_body_is_refused_where_the_profile_requires_it() {
723        let key = SigningKey::generate();
724        let set = sign_jwt(
725            &key,
726            &agent_token(&key),
727            &Outgoing {
728                method: "POST",
729                url: "https://api.booking.example/token",
730                content_type: None,
731                body: None,
732                created: 1_000,
733            },
734        )
735        .unwrap();
736        let refused = round_trip(&set, "POST", "/token", None, Some(b"{}"), 1_000).await;
737        assert_eq!(refused.unwrap_err().code, "invalid_input");
738    }
739
740    #[tokio::test]
741    async fn an_hwk_signature_carries_its_own_key() {
742        let key = SigningKey::generate();
743        let set = sign(
744            &key,
745            &SignatureKey::Hwk,
746            &Outgoing {
747                method: "GET",
748                url: "https://api.booking.example/v1/search",
749                content_type: None,
750                body: None,
751                created: 1_000,
752            },
753        )
754        .unwrap();
755        let verified = round_trip(&set, "GET", "/v1/search", None, None, 1_000)
756            .await
757            .unwrap();
758        assert!(matches!(verified.signer, Signer::Hwk));
759        assert_eq!(verified.thumbprint, key.thumbprint());
760    }
761
762    #[test]
763    fn a_token_that_would_break_the_header_is_refused() {
764        let key = SigningKey::generate();
765        let out = Outgoing {
766            method: "GET",
767            url: "https://a.example/",
768            content_type: None,
769            body: None,
770            created: 0,
771        };
772        assert!(sign_jwt(&key, "a\"b", &out).is_err());
773    }
774
775    /// A resolver that records every lookup, so a test can prove none happened.
776    #[derive(Default)]
777    struct Recording(std::sync::Mutex<Vec<String>>);
778
779    #[async_trait::async_trait]
780    impl KeyResolver for Recording {
781        async fn jwks(&self, issuer: &str, dwk: &str) -> crate::Result<crate::jwk::Jwks> {
782            self.0.lock().unwrap().push(format!("{issuer} {dwk}"));
783            Err(crate::Error::Discovery("none".into()))
784        }
785    }
786
787    #[tokio::test]
788    async fn a_jwks_uri_naming_an_internal_address_or_path_is_never_fetched() {
789        let key = SigningKey::generate();
790        for (id, dwk) in [
791            ("http://127.0.0.1:8080", "aauth-agent.json"),
792            ("https://10.0.0.1", "aauth-agent.json"),
793            ("https://ps.example", "../internal/admin?x=1"),
794        ] {
795            let set = sign(
796                &key,
797                &SignatureKey::JwksUri {
798                    id: id.into(),
799                    dwk: dwk.into(),
800                    kid: "k".into(),
801                },
802                &Outgoing {
803                    method: "GET",
804                    url: "https://api.booking.example/v1/search",
805                    content_type: None,
806                    body: None,
807                    created: 1_000,
808                },
809            )
810            .unwrap();
811            let headers = super::header_map(&set).unwrap();
812            let resolver = Recording::default();
813            let refused = verify(
814                &Incoming {
815                    method: "GET",
816                    authority: "api.booking.example",
817                    path: "/v1/search",
818                    query: None,
819                    headers: &headers,
820                    body: None,
821                },
822                &VerifyOptions {
823                    now: 1_000,
824                    ..Default::default()
825                },
826                &resolver,
827            )
828            .await
829            .unwrap_err();
830            assert_eq!(refused.code, "invalid_key", "{id} {dwk}");
831            assert!(
832                resolver.0.lock().unwrap().is_empty(),
833                "{id} {dwk} was fetched"
834            );
835        }
836    }
837
838    #[tokio::test]
839    async fn a_component_covered_twice_is_refused() {
840        let key = SigningKey::generate();
841        let mut set = sign_jwt(
842            &key,
843            &agent_token(&key),
844            &Outgoing {
845                method: "GET",
846                url: "https://api.booking.example/v1/search",
847                content_type: None,
848                body: None,
849                created: 1_000,
850            },
851        )
852        .unwrap();
853        for (name, value) in set.iter_mut() {
854            if name == "signature-input" {
855                *value = value.replace("\"@path\"", "\"@path\" \"@path\"");
856            }
857        }
858        let refused = round_trip(&set, "GET", "/v1/search", None, None, 1_000)
859            .await
860            .unwrap_err();
861        assert_eq!(refused.code, "invalid_input");
862    }
863}