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
20pub struct SignedUrls {
22 keys: Arc<SigningKeys>,
23 clock: Clock,
24}
25
26impl SignedUrls {
27 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 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
51pub 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
104fn is_signed(path_and_query: &str, keys: &SigningKeys, now: Timestamp) -> bool {
106 let Some((signed, signature)) = path_and_query.rsplit_once(SIGNATURE) else {
107 return false;
108 };
109 let expires = signed
110 .rsplit(['?', '&'])
111 .next()
112 .and_then(|parameter| parameter.strip_prefix(EXPIRES))
113 .and_then(|seconds| seconds.parse::<i64>().ok());
114 expires.is_some_and(|expires| now.as_second() < expires)
115 && keys.verifies(sofie_auth::Signed::Link, signed.as_bytes(), signature)
116}
117
118#[cfg(test)]
119mod tests {
120 use sofie_core::TestClock;
121
122 use super::*;
123
124 fn signer(clock: &TestClock) -> (SignedUrls, Arc<SigningKeys>) {
125 let keys = Arc::new(SigningKeys::generate().unwrap());
126 let urls = SignedUrls {
127 keys: Arc::clone(&keys),
128 clock: clock.clock(),
129 };
130 (urls, keys)
131 }
132
133 #[test]
134 fn a_signed_path_opens_until_it_expires() {
135 let clock = TestClock::starting_at(Timestamp::from_second(1_700_000_000).unwrap());
136 let (urls, keys) = signer(&clock);
137 let minute = Duration::from_secs(60);
138
139 let link = urls.sign("/files/7?size=large", minute).unwrap();
140
141 assert!(link.starts_with("/files/7?size=large&expires=1700000060&signature="));
142 assert!(is_signed(&link, &keys, clock.clock().now().timestamp()));
143 clock.advance(minute).unwrap();
144 assert!(!is_signed(&link, &keys, clock.clock().now().timestamp()));
145 }
146
147 #[test]
148 fn a_changed_or_unsigned_path_does_not_open() {
149 let clock = TestClock::starting_at(Timestamp::from_second(1_700_000_000).unwrap());
150 let (urls, keys) = signer(&clock);
151 let now = clock.clock().now().timestamp();
152 let link = urls.sign("/files/7", Duration::from_secs(60)).unwrap();
153
154 for forged in [
155 link.replace("/files/7", "/files/8"),
156 link.replace("expires=1700000060", "expires=1800000000"),
157 format!("{link}&size=large"),
158 "/files/7".to_owned(),
159 "/files/7?expires=1800000000&signature=".to_owned(),
160 ] {
161 assert!(!is_signed(&forged, &keys, now), "{forged}");
162 }
163 let other_keys = SigningKeys::generate().unwrap();
164 assert!(!is_signed(&link, &other_keys, now));
165 assert!(
166 urls.sign("https://evil.test/x", Duration::from_secs(1))
167 .is_err()
168 );
169 }
170
171 #[test]
172 fn a_path_a_browser_would_not_send_as_signed_is_refused() {
173 let clock = TestClock::starting_at(Timestamp::from_second(1_700_000_000).unwrap());
174 let (urls, _) = signer(&clock);
175 let minute = Duration::from_secs(60);
176
177 assert!(urls.sign("/files/café", minute).is_err());
178 assert!(urls.sign("/files/a b", minute).is_err());
179 assert!(urls.sign("/files/7#page=2", minute).is_err());
180 assert!(urls.sign("/files/caf%C3%A9", minute).is_ok());
181 }
182}