Skip to main content

sofie_http/
signed.rs

1use sofie_db::{Context, Resolve};
2use std::sync::Arc;
3use std::time::Duration;
4
5use http::StatusCode;
6use sofie_auth::SigningKeys;
7use sofie_core::{Clock, Error, Result, Timestamp};
8use sofie_db::worker::JobContext;
9
10use crate::admission::Visit;
11use crate::answer::{self, Response};
12use crate::caller::Caller;
13use crate::guard::is_local_path;
14use crate::{Area, RequestContext};
15
16const EXPIRES: &str = "expires=";
17const SIGNATURE: &str = "&signature=";
18const UNSIGNED: &str = "This link is invalid or has expired.";
19
20/// Opens a `.signed()` route without a session until it expires.
21pub struct SignedUrls {
22    keys: Arc<SigningKeys>,
23    clock: Clock,
24}
25
26impl SignedUrls {
27    /// Anything added to the link later breaks the signature.
28    pub fn sign(&self, path: &str, valid_for: Duration) -> Result<String> {
29        if !is_local_path(path) {
30            return Err(Error::internal_message(format!(
31                "only a path of this app can be signed, not `{path}`"
32            )));
33        }
34        // A browser percent-encodes anything else and keeps a fragment, so the signature would fail.
35        if !path
36            .bytes()
37            .all(|byte| byte.is_ascii_graphic() && byte != b'#')
38        {
39            return Err(Error::internal_message(format!(
40                "a signed path must be percent-encoded first (`percent_encode`), not `{path}`"
41            )));
42        }
43        let expires = self.clock.now().checked_add(valid_for)?.timestamp();
44        let separator = if path.contains('?') { '&' } else { '?' };
45        let signed = format!("{path}{separator}{EXPIRES}{}", expires.as_second());
46        let signature = self.keys.sign(sofie_auth::Signed::Link, signed.as_bytes());
47        Ok(format!("{signed}{SIGNATURE}{signature}"))
48    }
49}
50
51/// Encrypts small values with the app's keys, each for one purpose: a value sealed for
52/// one purpose never opens for another, and none opens without the keys.
53pub struct Sealing {
54    keys: Arc<SigningKeys>,
55}
56
57impl Sealing {
58    pub fn seal(&self, purpose: &str, plain: &[u8]) -> Result<String> {
59        self.keys.seal_value(purpose, plain)
60    }
61
62    #[must_use]
63    pub fn open(&self, purpose: &str, sealed: &str) -> Option<Vec<u8>> {
64        self.keys.open_value(purpose, sealed)
65    }
66}
67
68impl<A: Area, M> Resolve<RequestContext<A, M>> for Sealing {
69    fn resolve(context: &mut RequestContext<A, M>) -> impl Future<Output = Result<Self>> + Send {
70        std::future::ready(Ok(Self {
71            keys: Arc::clone(&context.matched.keys),
72        }))
73    }
74}
75
76impl<A: Area, M> Resolve<RequestContext<A, M>> for SignedUrls {
77    fn resolve(context: &mut RequestContext<A, M>) -> impl Future<Output = Result<Self>> + Send {
78        std::future::ready(Ok(Self {
79            keys: Arc::clone(&context.matched.keys),
80            clock: context.clock(),
81        }))
82    }
83}
84
85impl Resolve<JobContext> for SignedUrls {
86    fn resolve(context: &mut JobContext) -> impl Future<Output = Result<Self>> + Send {
87        std::future::ready(context.signing_keys().map(|keys| Self {
88            keys,
89            clock: context.clock(),
90        }))
91    }
92}
93
94pub(crate) fn is_signed_request(visit: Visit<'_>) -> bool {
95    let uri = &visit.asked.request.uri;
96    uri.path_and_query()
97        .is_some_and(|path| is_signed(path.as_str(), visit.keys, visit.now))
98}
99
100pub(crate) fn unsigned(caller: Caller<'_>) -> Response {
101    answer::refusal(StatusCode::FORBIDDEN, UNSIGNED, caller)
102}
103
104/// The verified `expires` and `signature` belong to the link, not to the query a handler reads.
105pub(crate) fn unsigned_query(query: &str) -> &str {
106    let Some((signed, _)) = query.rsplit_once(SIGNATURE) else {
107        return query;
108    };
109    match signed.rsplit_once('&') {
110        Some((asked, expires)) if expires.starts_with(EXPIRES) => asked,
111        None if signed.starts_with(EXPIRES) => "",
112        _ => query,
113    }
114}
115
116/// The signature comes last, right after the expiry it covers.
117fn is_signed(path_and_query: &str, keys: &SigningKeys, now: Timestamp) -> bool {
118    let Some((signed, signature)) = path_and_query.rsplit_once(SIGNATURE) else {
119        return false;
120    };
121    let expires = signed
122        .rsplit(['?', '&'])
123        .next()
124        .and_then(|parameter| parameter.strip_prefix(EXPIRES))
125        .and_then(|seconds| seconds.parse::<i64>().ok());
126    expires.is_some_and(|expires| now.as_second() < expires)
127        && keys.verifies(sofie_auth::Signed::Link, signed.as_bytes(), signature)
128}
129
130#[cfg(test)]
131mod tests {
132    use sofie_core::TestClock;
133
134    use super::*;
135
136    fn signer(clock: &TestClock) -> (SignedUrls, Arc<SigningKeys>) {
137        let keys = Arc::new(SigningKeys::generate().unwrap());
138        let urls = SignedUrls {
139            keys: Arc::clone(&keys),
140            clock: clock.clock(),
141        };
142        (urls, keys)
143    }
144
145    #[test]
146    fn a_signed_query_reads_without_its_expiry_and_signature() {
147        assert_eq!(
148            unsigned_query("size=large&expires=9&signature=abc"),
149            "size=large"
150        );
151        assert_eq!(unsigned_query("expires=9&signature=abc"), "");
152        assert_eq!(unsigned_query("size=large"), "size=large");
153    }
154
155    #[test]
156    fn a_signed_path_opens_until_it_expires() {
157        let clock = TestClock::starting_at(Timestamp::from_second(1_700_000_000).unwrap());
158        let (urls, keys) = signer(&clock);
159        let minute = Duration::from_secs(60);
160
161        let link = urls.sign("/files/7?size=large", minute).unwrap();
162
163        assert!(link.starts_with("/files/7?size=large&expires=1700000060&signature="));
164        assert!(is_signed(&link, &keys, clock.clock().now().timestamp()));
165        clock.advance(minute).unwrap();
166        assert!(!is_signed(&link, &keys, clock.clock().now().timestamp()));
167    }
168
169    #[test]
170    fn a_changed_or_unsigned_path_does_not_open() {
171        let clock = TestClock::starting_at(Timestamp::from_second(1_700_000_000).unwrap());
172        let (urls, keys) = signer(&clock);
173        let now = clock.clock().now().timestamp();
174        let link = urls.sign("/files/7", Duration::from_secs(60)).unwrap();
175
176        for forged in [
177            link.replace("/files/7", "/files/8"),
178            link.replace("expires=1700000060", "expires=1800000000"),
179            format!("{link}&size=large"),
180            "/files/7".to_owned(),
181            "/files/7?expires=1800000000&signature=".to_owned(),
182        ] {
183            assert!(!is_signed(&forged, &keys, now), "{forged}");
184        }
185        let other_keys = SigningKeys::generate().unwrap();
186        assert!(!is_signed(&link, &other_keys, now));
187        assert!(
188            urls.sign("https://evil.test/x", Duration::from_secs(1))
189                .is_err()
190        );
191    }
192
193    #[test]
194    fn a_path_a_browser_would_not_send_as_signed_is_refused() {
195        let clock = TestClock::starting_at(Timestamp::from_second(1_700_000_000).unwrap());
196        let (urls, _) = signer(&clock);
197        let minute = Duration::from_secs(60);
198
199        assert!(urls.sign("/files/café", minute).is_err());
200        assert!(urls.sign("/files/a b", minute).is_err());
201        assert!(urls.sign("/files/7#page=2", minute).is_err());
202        assert!(urls.sign("/files/caf%C3%A9", minute).is_ok());
203    }
204}