Skip to main content

truss/adapters/server/
signing.rs

1/// Signed URL generation and bind address resolution.
2use hmac::{Hmac, KeyInit, Mac};
3use sha2::Sha256;
4use url::Url;
5
6use super::auth::{
7    canonical_query_without_signature, extend_transform_query, signed_source_query, url_authority,
8};
9use super::config::DEFAULT_BIND_ADDR;
10use crate::TransformOptions;
11
12pub(super) type HmacSha256 = Hmac<Sha256>;
13
14/// Source selector used when generating a signed public transform URL.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum SignedUrlSource {
18    /// Generates a signed `GET /images/by-path` URL.
19    Path {
20        /// The storage-relative source path.
21        path: String,
22        /// An optional source version token.
23        version: Option<String>,
24    },
25    /// Generates a signed `GET /images/by-url` URL.
26    Url {
27        /// The remote source URL.
28        url: String,
29        /// An optional source version token.
30        version: Option<String>,
31    },
32}
33
34/// Builds a signed public transform URL for the server adapter.
35///
36/// The resulting URL targets either `GET /images/by-path` or `GET /images/by-url` depending on
37/// `source`. `base_url` must be an absolute `http` or `https` URL that points at the externally
38/// visible server origin. The helper applies the same canonical query and HMAC-SHA256 signature
39/// scheme that the server adapter verifies at request time.
40///
41/// The helper serializes only explicitly requested transform options and omits fields that would
42/// resolve to the documented defaults on the server side.
43///
44/// # Errors
45///
46/// Returns an error string when `base_url` is not an absolute `http` or `https` URL, when the
47/// visible authority cannot be determined, or when the HMAC state cannot be initialized.
48///
49/// # Examples
50///
51/// ```
52/// use truss::{sign_public_url, SignedUrlSource};
53/// use truss::{MediaType, TransformOptions};
54///
55/// let mut options = TransformOptions::default();
56/// options.format = Some(MediaType::Jpeg);
57///
58/// let url = sign_public_url(
59///     "https://cdn.example.com",
60///     SignedUrlSource::Path {
61///         path: "/image.png".to_string(),
62///         version: None,
63///     },
64///     &options,
65///     "public-dev",
66///     "secret-value",
67///     4_102_444_800,
68///     None,
69///     None,
70/// )
71/// .unwrap();
72///
73/// assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
74/// assert!(url.contains("keyId=public-dev"));
75/// assert!(url.contains("signature="));
76/// ```
77/// Optional watermark parameters for signed URL generation.
78///
79/// Each field other than the URL is `None` when the caller does not name it, and the server
80/// then applies the same default it applies to a watermark from any other adapter.
81#[derive(Debug, Default)]
82#[non_exhaustive]
83pub struct SignedWatermarkParams {
84    /// The URL the watermark image is fetched from.
85    pub url: String,
86    /// Where to place the watermark, as the name the vocabulary uses.
87    pub position: Option<String>,
88    /// Opacity of the watermark, 1 to 100.
89    pub opacity: Option<u8>,
90    /// Margin in pixels from the nearest edge.
91    pub margin: Option<u32>,
92}
93
94impl SignedWatermarkParams {
95    /// Names the watermark image and leaves every other parameter to the server's default.
96    ///
97    /// A caller assigns the rest afterwards. The struct is `#[non_exhaustive]`, so a
98    /// parameter the watermark vocabulary gains later is a minor change rather than a
99    /// breaking one.
100    #[must_use]
101    pub fn new(url: impl Into<String>) -> Self {
102        Self {
103            url: url.into(),
104            ..Self::default()
105        }
106    }
107}
108
109#[allow(clippy::too_many_arguments)]
110pub fn sign_public_url(
111    base_url: &str,
112    source: SignedUrlSource,
113    options: &TransformOptions,
114    key_id: &str,
115    secret: &str,
116    expires: u64,
117    watermark: Option<&SignedWatermarkParams>,
118    preset: Option<&str>,
119) -> Result<String, String> {
120    sign_public_url_with_method(
121        "GET", base_url, source, options, key_id, secret, expires, watermark, preset,
122    )
123}
124
125/// Names the reason a set of signing inputs can never produce a URL the server accepts.
126///
127/// A key id and a secret are refused by `ServerConfig::from_env`, which will not start a
128/// server whose `TRUSS_SIGNING_KEYS` holds an empty one, and an empty source is refused by
129/// the route that reads it, so a URL carrying any of them is answered 400 or 401 for as
130/// long as it exists. A signed URL is usually written somewhere other than where it is
131/// fetched, so the signer refuses them rather than the request.
132///
133/// The signer and `truss sign` both read this, the way both read
134/// [`TransformOptions::validate_without_input`] for the rules about the transform.
135pub(crate) fn signing_input_error(
136    key_id: &str,
137    secret: &str,
138    source: &SignedUrlSource,
139) -> Option<&'static str> {
140    if key_id.is_empty() {
141        return Some("key id must not be empty");
142    }
143    if secret.is_empty() {
144        return Some("secret must not be empty");
145    }
146    match source {
147        SignedUrlSource::Path { path, .. } if path.is_empty() => Some("path must not be empty"),
148        SignedUrlSource::Url { url, .. } if url.is_empty() => Some("url must not be empty"),
149        _ => None,
150    }
151}
152
153/// Like [`sign_public_url`] but allows the caller to specify the HTTP method
154/// included in the canonical string (e.g. `"GET"` or `"HEAD"`).
155#[allow(clippy::too_many_arguments)]
156pub fn sign_public_url_with_method(
157    method: &str,
158    base_url: &str,
159    source: SignedUrlSource,
160    options: &TransformOptions,
161    key_id: &str,
162    secret: &str,
163    expires: u64,
164    watermark: Option<&SignedWatermarkParams>,
165    preset: Option<&str>,
166) -> Result<String, String> {
167    let mut base_url =
168        Url::parse(base_url).map_err(|error| format!("base URL is invalid: {error}"))?;
169    match base_url.scheme() {
170        "http" | "https" => {}
171        _ => return Err("base URL must use the http or https scheme".to_string()),
172    }
173    if let Some(reason) = signing_input_error(key_id, secret, &source) {
174        return Err(reason.to_string());
175    }
176
177    let route_path = match source {
178        SignedUrlSource::Path { .. } => "/images/by-path",
179        SignedUrlSource::Url { .. } => "/images/by-url",
180    };
181    // The base URL may carry a path, which is a deployment served under a prefix by a
182    // proxy that strips it before truss sees the request. Resolving an absolute route path
183    // against it would drop the prefix, so the base path is given a trailing slash and the
184    // route is joined onto it as a relative reference.
185    if !base_url.path().ends_with('/') {
186        let with_slash = format!("{}/", base_url.path());
187        base_url.set_path(&with_slash);
188    }
189    let mut endpoint = base_url
190        .join(route_path.trim_start_matches('/'))
191        .map_err(|error| format!("failed to resolve the public endpoint URL: {error}"))?;
192    let authority = url_authority(&endpoint)?;
193    let mut query = signed_source_query(source);
194    if let Some(name) = preset {
195        query.insert("preset".to_string(), name.to_string());
196    }
197    extend_transform_query(&mut query, options);
198    if let Some(wm) = watermark {
199        query.insert("watermarkUrl".to_string(), wm.url.clone());
200        if let Some(ref pos) = wm.position {
201            query.insert("watermarkPosition".to_string(), pos.clone());
202        }
203        if let Some(opacity) = wm.opacity {
204            query.insert("watermarkOpacity".to_string(), opacity.to_string());
205        }
206        if let Some(margin) = wm.margin {
207            query.insert("watermarkMargin".to_string(), margin.to_string());
208        }
209    }
210    query.insert("keyId".to_string(), key_id.to_string());
211    query.insert("expires".to_string(), expires.to_string());
212
213    // REQUEST_PATH in `docs/signed-url-spec.md` is the literal endpoint path, which is what
214    // truss receives after a proxy has stripped whatever prefix the base URL carried. It is
215    // therefore the route rather than the path of the URL being emitted.
216    let canonical = format!(
217        "{}\n{}\n{}\n{}",
218        method.to_ascii_uppercase(),
219        authority,
220        route_path,
221        canonical_query_without_signature(&query)
222    );
223    let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
224        .map_err(|error| format!("failed to initialize signed URL HMAC: {error}"))?;
225    mac.update(canonical.as_bytes());
226    query.insert(
227        "signature".to_string(),
228        hex::encode(mac.finalize().into_bytes()),
229    );
230
231    let mut serializer = url::form_urlencoded::Serializer::new(String::new());
232    for (name, value) in query {
233        serializer.append_pair(&name, &value);
234    }
235    endpoint.set_query(Some(&serializer.finish()));
236    Ok(endpoint.into())
237}
238
239/// Returns the bind address for the HTTP server adapter.
240///
241/// The adapter reads `TRUSS_BIND_ADDR` when it is present, and falls back to
242/// `127.0.0.1:8080`. This is the only way to learn the address truss would bind, since
243/// [`serve_with_config`](crate::serve_with_config) takes a listener the caller has already
244/// bound.
245pub fn bind_addr() -> String {
246    std::env::var("TRUSS_BIND_ADDR").unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string())
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::{OptimizeMode, TargetQuality, TransformOptions};
253
254    #[test]
255    fn sign_public_url_rejects_invalid_base_url() {
256        let result = sign_public_url(
257            "not-a-url",
258            SignedUrlSource::Path {
259                path: "/img.png".to_string(),
260                version: None,
261            },
262            &TransformOptions::default(),
263            "key",
264            "secret",
265            0,
266            None,
267            None,
268        );
269        assert!(result.is_err());
270        assert!(result.unwrap_err().contains("base URL is invalid"));
271    }
272
273    #[test]
274    fn sign_public_url_rejects_non_http_scheme() {
275        let result = sign_public_url(
276            "ftp://example.com",
277            SignedUrlSource::Path {
278                path: "/img.png".to_string(),
279                version: None,
280            },
281            &TransformOptions::default(),
282            "key",
283            "secret",
284            0,
285            None,
286            None,
287        );
288        assert!(result.is_err());
289        assert!(result.unwrap_err().contains("http or https"));
290    }
291
292    #[test]
293    fn sign_public_url_path_source_generates_by_path_url() {
294        let url = sign_public_url(
295            "https://cdn.example.com",
296            SignedUrlSource::Path {
297                path: "/photo.jpg".to_string(),
298                version: None,
299            },
300            &TransformOptions::default(),
301            "mykey",
302            "mysecret",
303            9999,
304            None,
305            None,
306        )
307        .unwrap();
308        assert!(url.starts_with("https://cdn.example.com/images/by-path?"));
309        assert!(url.contains("keyId=mykey"));
310        assert!(url.contains("signature="));
311        assert!(url.contains("expires=9999"));
312    }
313
314    #[test]
315    fn sign_public_url_url_source_generates_by_url() {
316        let url = sign_public_url(
317            "https://cdn.example.com",
318            SignedUrlSource::Url {
319                url: "https://remote.example.com/img.png".to_string(),
320                version: None,
321            },
322            &TransformOptions::default(),
323            "key",
324            "secret",
325            0,
326            None,
327            None,
328        )
329        .unwrap();
330        assert!(url.starts_with("https://cdn.example.com/images/by-url?"));
331    }
332
333    #[test]
334    fn sign_public_url_includes_preset() {
335        let url = sign_public_url(
336            "https://cdn.example.com",
337            SignedUrlSource::Path {
338                path: "/img.png".to_string(),
339                version: None,
340            },
341            &TransformOptions::default(),
342            "key",
343            "secret",
344            0,
345            None,
346            Some("thumbnail"),
347        )
348        .unwrap();
349        assert!(url.contains("preset=thumbnail"));
350    }
351
352    #[test]
353    fn sign_public_url_includes_watermark_params() {
354        let wm = SignedWatermarkParams {
355            url: "https://example.com/logo.png".to_string(),
356            position: Some("southeast".to_string()),
357            opacity: Some(80),
358            margin: Some(10),
359        };
360        let url = sign_public_url(
361            "https://cdn.example.com",
362            SignedUrlSource::Path {
363                path: "/img.png".to_string(),
364                version: None,
365            },
366            &TransformOptions::default(),
367            "key",
368            "secret",
369            0,
370            Some(&wm),
371            None,
372        )
373        .unwrap();
374        assert!(url.contains("watermarkUrl="));
375        assert!(url.contains("watermarkPosition=southeast"));
376        assert!(url.contains("watermarkOpacity=80"));
377        assert!(url.contains("watermarkMargin=10"));
378    }
379
380    #[test]
381    fn sign_public_url_includes_optimize_params() {
382        let url = sign_public_url(
383            "https://cdn.example.com",
384            SignedUrlSource::Path {
385                path: "/img.png".to_string(),
386                version: None,
387            },
388            &TransformOptions {
389                format: Some(crate::MediaType::Jpeg),
390                optimize: OptimizeMode::Lossy,
391                target_quality: Some("ssim:0.98".parse::<TargetQuality>().unwrap()),
392                ..TransformOptions::default()
393            },
394            "key",
395            "secret",
396            0,
397            None,
398            None,
399        )
400        .unwrap();
401
402        assert!(url.contains("optimize=lossy"));
403        assert!(url.contains("targetQuality=ssim%3A0.98"));
404    }
405
406    /// The three inputs the server can never accept, whatever the request carries.
407    ///
408    /// An empty key id and an empty secret are refused by the configuration parser before
409    /// a server binds a port, and an empty path is refused by the by-path route, so a URL
410    /// carrying one of them is a URL that will be answered 400 or 401 for as long as it
411    /// exists.
412    #[test]
413    fn sign_public_url_refuses_inputs_no_server_can_accept() {
414        let sign = |key_id: &str, secret: &str, source: SignedUrlSource| {
415            sign_public_url(
416                "https://images.example.com",
417                source,
418                &TransformOptions::default(),
419                key_id,
420                secret,
421                1_900_000_000,
422                None,
423                None,
424            )
425        };
426        let path = |path: &str| SignedUrlSource::Path {
427            path: path.to_string(),
428            version: None,
429        };
430
431        assert_eq!(
432            sign("", "secret-value", path("/image.png")),
433            Err("key id must not be empty".to_string())
434        );
435        assert_eq!(
436            sign("public-demo", "", path("/image.png")),
437            Err("secret must not be empty".to_string())
438        );
439        assert_eq!(
440            sign("public-demo", "secret-value", path("")),
441            Err("path must not be empty".to_string())
442        );
443        assert_eq!(
444            sign(
445                "public-demo",
446                "secret-value",
447                SignedUrlSource::Url {
448                    url: String::new(),
449                    version: None,
450                },
451            ),
452            Err("url must not be empty".to_string())
453        );
454        assert!(sign("public-demo", "secret-value", path("/image.png")).is_ok());
455    }
456
457    /// A base URL with a path prefix points at a deployment behind a proxy that serves
458    /// truss under it, and the prefix has to survive into the emitted URL.
459    ///
460    /// The signature must not move: the canonical string carries the literal endpoint path
461    /// the server sees after the proxy has stripped the prefix, which is what
462    /// `docs/signed-url-spec.md` calls REQUEST_PATH.
463    #[test]
464    fn sign_public_url_keeps_a_path_in_the_base_url() {
465        let sign = |base_url: &str| {
466            sign_public_url(
467                base_url,
468                SignedUrlSource::Path {
469                    path: "image.png".to_string(),
470                    version: None,
471                },
472                &TransformOptions::default(),
473                "public-demo",
474                "secret-value",
475                1_900_000_000,
476                None,
477                None,
478            )
479            .expect("sign")
480        };
481
482        let plain = sign("https://images.example.com");
483        let signature = |url: &str| {
484            url.split("signature=")
485                .nth(1)
486                .expect("a signature")
487                .split('&')
488                .next()
489                .expect("the signature value")
490                .to_string()
491        };
492
493        for base in [
494            "https://images.example.com/img",
495            "https://images.example.com/img/",
496        ] {
497            let prefixed = sign(base);
498            assert!(
499                prefixed.starts_with("https://images.example.com/img/images/by-path?"),
500                "the prefix has to reach the emitted URL, got: {prefixed}"
501            );
502            assert_eq!(
503                signature(&prefixed),
504                signature(&plain),
505                "the canonical string carries the endpoint path, not the base URL's"
506            );
507        }
508
509        assert!(
510            sign("https://images.example.com/")
511                .starts_with("https://images.example.com/images/by-path?")
512        );
513    }
514
515    #[test]
516    fn sign_public_url_matches_fixed_compatibility_vector() {
517        let url = sign_public_url(
518            "https://images.example.com",
519            SignedUrlSource::Path {
520                path: "image.png".to_string(),
521                version: None,
522            },
523            &TransformOptions {
524                width: Some(800),
525                format: Some(crate::MediaType::Webp),
526                ..TransformOptions::default()
527            },
528            "public-demo",
529            "secret-value",
530            1_900_000_000,
531            None,
532            None,
533        )
534        .unwrap();
535
536        assert_eq!(
537            url,
538            "https://images.example.com/images/by-path?expires=1900000000&format=webp&keyId=public-demo&path=image.png&signature=8c3234125e0e20efeaae1e2afaa88a81d387c82cef0080780fddd31c5689199e&width=800"
539        );
540    }
541}