1use crate::dav;
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::time::{Duration, Instant};
38use zeroize::Zeroizing;
39
40pub const MURENA: &str = "https://murena.io";
42
43pub const LIMIT: Duration = Duration::from_secs(20 * 60);
45
46pub const EVERY: Duration = Duration::from_secs(3);
48
49const FAILURES: u32 = 10;
52
53const ANSWER: u64 = 64 << 10;
55
56pub type Secret = Zeroizing<String>;
58
59pub fn secret(text: &str) -> Secret {
61 Zeroizing::new(text.to_string())
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum Refusal {
67 Address,
69 NotHttps(String),
71 Unreachable(String),
73 Tls(String),
75 NotNextcloud(String),
77 Elsewhere(String, String),
81 Server(String),
83 TimedOut,
85 Cancelled,
87}
88
89impl Refusal {
90 pub fn code(&self) -> String {
92 match self {
93 Refusal::Address => "address".into(),
94 Refusal::NotHttps(host) => format!("not-https:{host}"),
95 Refusal::Unreachable(host) => format!("unreachable:{host}"),
96 Refusal::Tls(host) => format!("tls:{host}"),
97 Refusal::NotNextcloud(host) => format!("not-nextcloud:{host}"),
98 Refusal::Elsewhere(chosen, named) => format!("elsewhere:{chosen}:{named}"),
99 Refusal::Server(host) => format!("server:{host}"),
100 Refusal::TimedOut => "timed-out".into(),
101 Refusal::Cancelled => "cancelled".into(),
102 }
103 }
104}
105
106pub fn address_of(typed: &str) -> Result<String, Refusal> {
112 let typed = typed.trim();
113 let (scheme, rest) = match typed.split_once("://") {
114 Some((scheme, rest)) => (scheme.to_ascii_lowercase(), rest),
115 None => ("https".to_string(), typed),
116 };
117 let rest = rest.split(['?', '#']).next().unwrap_or("");
118 let (authority, path) = rest.split_once('/').unwrap_or((rest, ""));
119 if authority.is_empty() || authority.contains('@') || authority.chars().any(|c| c.is_whitespace() || c.is_control()) || authority.starts_with(':') {
120 return Err(Refusal::Address);
121 }
122 let authority = authority.to_ascii_lowercase();
123 let mut kept: Vec<&str> = Vec::new();
124 for part in path.split('/').filter(|p| !p.is_empty()) {
125 if matches!(part, "index.php" | "login" | "apps" | "remote.php" | "ocs" | "status.php" | "settings") {
126 break;
127 }
128 kept.push(part);
129 }
130 let base = if kept.is_empty() { format!("{scheme}://{authority}") } else { format!("{scheme}://{authority}/{}", kept.join("/")) };
131 if dav::allowed(&base).is_err() {
132 return Err(Refusal::NotHttps(crate::remote::host_of(&base)));
133 }
134 if dav::origin(&base).is_none() {
135 return Err(Refusal::Address);
136 }
137 Ok(base)
138}
139
140pub struct Flow {
143 base: String,
145 origin: String,
146 login: Zeroizing<String>,
147 endpoint: String,
148 token: Zeroizing<String>,
149 client: String,
152}
153
154impl std::fmt::Debug for Flow {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 f.debug_struct("Flow").field("base", &self.base).field("login", &"…").field("token", &"…").finish()
157 }
158}
159
160impl Flow {
161 pub fn page(&self) -> &str {
163 &self.login
164 }
165
166 pub fn host(&self) -> String {
168 crate::remote::host_of(&self.base)
169 }
170}
171
172pub struct Grant {
175 pub server: String,
176 pub login: String,
177 password: Zeroizing<String>,
178}
179
180impl std::fmt::Debug for Grant {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("Grant").field("server", &self.server).field("password", &"…").finish()
183 }
184}
185
186impl Grant {
187 pub fn password(&self) -> &str {
189 &self.password
190 }
191
192 pub fn host(&self) -> String {
194 crate::remote::host_of(&self.server)
195 }
196
197 pub fn login_for(&self, account: &str) -> crate::remote::Login {
200 crate::remote::Login { account: account.to_string(), url: format!("{}/remote.php/dav/", self.server), user: self.login.clone(), password: Some(self.password.to_string()) }
201 }
202
203 pub fn of(server: &str, login: &str, password: &str) -> Grant {
206 Grant { server: server.trim_end_matches('/').to_string(), login: login.to_string(), password: Zeroizing::new(password.to_string()) }
207 }
208}
209
210fn agent() -> ureq::Agent {
212 dav::agent(&dav::Budget { connect: Duration::from_secs(10), stall: Duration::from_secs(15), whole: Duration::from_secs(30) })
213}
214
215fn refused(e: &ureq::Error, host: &str) -> Refusal {
216 match e {
217 ureq::Error::Tls(_) => Refusal::Tls(host.to_string()),
218 _ if format!("{e:?}").starts_with("Rustls") => Refusal::Tls(host.to_string()),
219 _ => Refusal::Unreachable(host.to_string()),
220 }
221}
222
223fn read(response: &mut ureq::http::Response<ureq::Body>) -> Zeroizing<String> {
225 Zeroizing::new(response.body_mut().with_config().limit(ANSWER).read_to_string().unwrap_or_default())
226}
227
228fn on(url: &str, origin: &str) -> bool {
230 dav::allowed(url).is_ok() && dav::origin(url).as_deref() == Some(origin)
231}
232
233#[derive(serde::Deserialize)]
234struct Begun {
235 login: String,
236 poll: Poll,
237}
238
239#[derive(serde::Deserialize)]
240struct Poll {
241 token: String,
242 endpoint: String,
243}
244
245#[derive(serde::Deserialize)]
246struct Answered {
247 server: String,
248 #[serde(rename = "loginName")]
249 login_name: String,
250 #[serde(rename = "appPassword")]
251 app_password: String,
252}
253
254pub fn begin(base: &str, client: &str) -> Result<Flow, Refusal> {
258 let host = crate::remote::host_of(base);
259 let origin = dav::origin(base).ok_or(Refusal::Address)?;
260 let url = format!("{}/index.php/login/v2", base.trim_end_matches('/'));
261 dav::allowed(&url).map_err(|_| Refusal::NotHttps(host.clone()))?;
262 let request = ureq::http::Request::builder().method("POST").uri(&url).header("User-Agent", client).header("Accept", "application/json").body(Vec::new()).map_err(|_| Refusal::Address)?;
263 let mut response = agent().run(request).map_err(|e| refused(&e, &host))?;
264 let status = response.status().as_u16();
265 let body = read(&mut response);
266 match status {
267 200 => {}
268 404 | 405 => return Err(Refusal::NotNextcloud(host)),
269 _ => return Err(Refusal::Server(host)),
270 }
271 let begun: Begun = serde_json::from_str(&body).map_err(|_| Refusal::NotNextcloud(host.clone()))?;
272 let (login, token) = (Zeroizing::new(begun.login), Zeroizing::new(begun.poll.token));
273 for named in [login.as_str(), begun.poll.endpoint.as_str()] {
274 if !on(named, &origin) {
275 return Err(Refusal::Elsewhere(host, crate::remote::host_of(named)));
276 }
277 }
278 if token.is_empty() || token.len() > 1024 || !token.chars().all(|c| c.is_ascii_alphanumeric()) {
279 return Err(Refusal::NotNextcloud(host));
280 }
281 Ok(Flow { base: base.trim_end_matches('/').to_string(), origin, login, endpoint: begun.poll.endpoint, token, client: client.to_string() })
282}
283
284pub fn poll(flow: &Flow) -> Result<Option<Grant>, Refusal> {
289 let host = crate::remote::host_of(&flow.base);
290 let body = Zeroizing::new(format!("token={}", flow.token.as_str()).into_bytes());
291 let request = ureq::http::Request::builder()
292 .method("POST")
293 .uri(&flow.endpoint)
294 .header("User-Agent", flow.client.as_str())
295 .header("Accept", "application/json")
296 .header("Content-Type", "application/x-www-form-urlencoded")
297 .body(body.to_vec())
298 .map_err(|_| Refusal::Server(host.clone()))?;
299 let mut response = agent().run(request).map_err(|e| refused(&e, &host))?;
300 let status = response.status().as_u16();
301 let answer = read(&mut response);
302 match status {
303 200 => {}
304 404 => return Ok(None),
305 _ => return Err(Refusal::Server(host)),
306 }
307 let answered: Answered = serde_json::from_str(&answer).map_err(|_| Refusal::NotNextcloud(host.clone()))?;
308 let password = Zeroizing::new(answered.app_password);
309 let server = answered.server.trim().trim_end_matches('/').to_string();
310 if !on(&server, &flow.origin) {
311 let named = crate::remote::host_of(&server);
313 let _ = revoke(&Grant { server: flow.base.clone(), login: answered.login_name, password });
314 return Err(if named == host { Refusal::NotHttps(host) } else { Refusal::Elsewhere(host, named) });
316 }
317 if answered.login_name.trim().is_empty() || password.is_empty() {
318 return Err(Refusal::NotNextcloud(host));
319 }
320 Ok(Some(Grant { server, login: answered.login_name.trim().to_string(), password }))
321}
322
323pub fn wait(flow: &Flow, stop: &AtomicBool, limit: Duration, every: Duration) -> Result<Grant, Refusal> {
327 let end = Instant::now() + limit;
328 let mut failures = 0;
329 loop {
330 if stop.load(Ordering::SeqCst) {
331 return Err(Refusal::Cancelled);
332 }
333 match poll(flow) {
334 Ok(Some(grant)) => {
335 if stop.load(Ordering::SeqCst) {
337 let _ = revoke(&grant);
338 return Err(Refusal::Cancelled);
339 }
340 return Ok(grant);
341 }
342 Ok(None) => failures = 0,
343 Err(e @ (Refusal::Elsewhere(..) | Refusal::Tls(_) | Refusal::NotNextcloud(_))) => return Err(e),
344 Err(e) => {
345 failures += 1;
346 if failures >= FAILURES {
347 return Err(e);
348 }
349 }
350 }
351 let next = Instant::now() + every;
352 while Instant::now() < next {
353 if stop.load(Ordering::SeqCst) {
354 return Err(Refusal::Cancelled);
355 }
356 if Instant::now() >= end {
357 return Err(Refusal::TimedOut);
358 }
359 std::thread::sleep(Duration::from_millis(100).min(every));
360 }
361 if Instant::now() >= end {
362 return Err(Refusal::TimedOut);
363 }
364 }
365}
366
367pub fn revoke(grant: &Grant) -> Result<(), Refusal> {
370 let host = grant.host();
371 let url = format!("{}/ocs/v2.php/core/apppassword", grant.server);
372 dav::allowed(&url).map_err(|_| Refusal::NotHttps(host.clone()))?;
373 let authorization = Zeroizing::new(format!("Basic {}", sioul_core::lines::base64_encode(format!("{}:{}", grant.login, grant.password()).as_bytes())));
374 let request = ureq::http::Request::builder()
375 .method("DELETE")
376 .uri(&url)
377 .header("Authorization", authorization.as_str())
378 .header("OCS-APIREQUEST", "true")
379 .header("User-Agent", "Sioul")
380 .body(Vec::new())
381 .map_err(|_| Refusal::Server(host.clone()))?;
382 let response = agent().run(request).map_err(|e| refused(&e, &host))?;
383 match response.status().as_u16() {
384 200 | 204 | 401 | 403 | 404 => Ok(()),
385 _ => Err(Refusal::Server(host)),
386 }
387}
388
389pub fn keyring_name(login: &str, host: &str) -> String {
393 format!("Nextcloud app password: {login} on {host}")
394}
395
396pub fn keep(grant: &Grant) -> Result<(), crate::SyncError> {
399 crate::secret::save_named(&keyring_name(&grant.login, &grant.host()), grant.password())
400}
401
402pub fn kept(login: &str, host: &str) -> Option<String> {
404 crate::secret::named(&keyring_name(login, host))
405}
406
407pub fn forget(login: &str, host: &str) -> Result<(), crate::SyncError> {
409 crate::secret::forget_named(&keyring_name(login, host))
410}
411
412pub fn homes(grant: &Grant) -> Result<dav::Homes, crate::SyncError> {
415 dav::discover_on(&dav::Client::new(&grant.login, grant.password()), &grant.server)
416}
417
418pub fn opens(grant: &Grant, login: &str) -> Result<bool, crate::SyncError> {
422 let client = dav::Client::new(login, grant.password());
423 match dav::discover_on(&client, &grant.server) {
424 Ok(_) => Ok(true),
425 Err(crate::SyncError::Login(_)) => Ok(false),
426 Err(e) => Err(e),
427 }
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433
434 #[test]
435 fn an_address_typed_is_made_whole_and_kept_to_https() {
436 assert_eq!(address_of("murena.io"), Ok("https://murena.io".to_string()));
437 assert_eq!(address_of(" https://Cloud.Example.org/nextcloud/ "), Ok("https://cloud.example.org/nextcloud".to_string()));
438 assert_eq!(address_of("https://cloud.example.org/index.php/apps/files/?dir=/"), Ok("https://cloud.example.org".to_string()));
439 assert_eq!(address_of("cloud.example.org:8443/nc/login"), Ok("https://cloud.example.org:8443/nc".to_string()));
440 assert_eq!(address_of("http://cloud.example.org"), Err(Refusal::NotHttps("cloud.example.org".into())));
441 for typed in ["", " ", "https://", "jane@cloud.example.org", "https://jane:secret@cloud.example.org", "cloud example.org"] {
442 assert_eq!(address_of(typed), Err(Refusal::Address), "{typed:?}");
443 }
444 }
445
446 #[test]
447 fn no_debug_shows_a_secret() {
448 let grant = Grant::of("https://cloud.example.org", "jane", "app-password-secret");
449 let said = format!("{grant:?}");
450 assert!(!said.contains("app-password-secret") && said.contains("cloud.example.org"), "{said}");
451 let flow = Flow { base: "https://cloud.example.org".into(), origin: "https://cloud.example.org:443".into(), login: Zeroizing::new("https://cloud.example.org/login/v2/flow/page-secret".into()), endpoint: "https://cloud.example.org/login/v2/poll".into(), token: Zeroizing::new("token-secret".into()), client: "Sioul".into() };
452 let said = format!("{flow:?}");
453 assert!(!said.contains("page-secret") && !said.contains("token-secret"), "{said}");
454 assert_eq!(Refusal::Elsewhere("murena.io".into(), "evil.example".into()).code(), "elsewhere:murena.io:evil.example");
455 }
456
457 use crate::remote::fake::{APP_PASSWORD, Fake, USER};
458 use std::sync::Arc;
459
460 fn stand_in(name: &str) -> (Arc<Fake>, std::path::PathBuf) {
462 let files = std::env::temp_dir().join(format!("sioul-loginflow-{name}-{}", std::process::id()));
463 let _ = std::fs::remove_dir_all(&files);
464 std::fs::create_dir_all(&files).unwrap();
465 (Fake::start(&files), files)
466 }
467
468 #[test]
469 fn a_sign_in_answered_gives_an_app_password_once_for_its_server_alone() {
470 let (fake, files) = stand_in("answered");
471 let base = address_of(&fake.base).unwrap();
472 let flow = begin(&base, "Sioul (test)").unwrap();
473 assert!(flow.page().starts_with(&format!("{base}/login/v2/flow/")), "the page on the server chosen");
474 assert!(poll(&flow).unwrap().is_none());
476 fake.grant_flows();
477 let grant = poll(&flow).unwrap().expect("signed in");
478 assert_eq!((grant.server.as_str(), grant.login.as_str()), (base.as_str(), USER));
479 assert!(grant.password().starts_with(APP_PASSWORD));
480 assert!(poll(&flow).unwrap().is_none());
482 assert!(fake.seen().iter().all(|line| !line.contains("polltoken")), "{:?}", fake.seen());
484 let homes = homes(&grant).unwrap();
486 assert_eq!(homes.calendars.as_deref(), Some(format!("{base}/remote.php/dav/calendars/{USER}/").as_str()));
487 assert_eq!(homes.contacts.as_deref(), Some(format!("{base}/remote.php/dav/addressbooks/users/{USER}/").as_str()));
488 assert!(opens(&grant, USER).unwrap());
489 assert!(!opens(&grant, "someone-else").unwrap(), "another login with it is refused there");
490 let flow = begin(&base, "Sioul (test)").unwrap();
492 let granting = Arc::clone(&fake);
493 let signed = std::thread::spawn(move || {
494 std::thread::sleep(Duration::from_millis(300));
495 granting.grant_flows();
496 });
497 let stop = AtomicBool::new(false);
498 let second = wait(&flow, &stop, Duration::from_secs(20), Duration::from_millis(50)).unwrap();
499 signed.join().unwrap();
500 assert_ne!(second.password(), grant.password(), "an app password of its own");
501 revoke(&second).unwrap();
503 assert!(!fake.app_passwords.lock().unwrap().iter().any(|p| p == second.password()));
504 assert!(matches!(homes_of(&second), Err(crate::SyncError::Login(_))));
505 let _ = std::fs::remove_dir_all(files);
506 }
507
508 fn homes_of(grant: &Grant) -> Result<crate::dav::Homes, crate::SyncError> {
509 homes(grant)
510 }
511
512 #[test]
513 fn a_sign_in_cancelled_or_left_twenty_minutes_keeps_nothing() {
514 let (fake, files) = stand_in("cancelled");
515 let base = address_of(&fake.base).unwrap();
516 let flow = begin(&base, "Sioul (test)").unwrap();
518 let stop = Arc::new(AtomicBool::new(false));
519 let pressing = Arc::clone(&stop);
520 let pressed = std::thread::spawn(move || {
521 std::thread::sleep(Duration::from_millis(200));
522 pressing.store(true, Ordering::SeqCst);
523 });
524 let started = Instant::now();
525 assert_eq!(wait(&flow, &stop, Duration::from_secs(20), Duration::from_millis(50)).unwrap_err(), Refusal::Cancelled);
526 pressed.join().unwrap();
527 assert!(started.elapsed() < Duration::from_secs(5), "stopped at once");
528 fake.grant_flows();
530 assert_eq!(wait(&flow, &stop, Duration::from_secs(20), Duration::from_millis(50)).unwrap_err(), Refusal::Cancelled);
531 assert!(fake.app_passwords.lock().unwrap().is_empty());
532 let flow = begin(&base, "Sioul (test)").unwrap();
534 let stop = AtomicBool::new(false);
535 assert_eq!(wait(&flow, &stop, Duration::from_millis(300), Duration::from_millis(50)).unwrap_err(), Refusal::TimedOut);
536 assert!(fake.app_passwords.lock().unwrap().is_empty());
537 assert_eq!(LIMIT, Duration::from_secs(20 * 60));
538 let _ = std::fs::remove_dir_all(files);
539 }
540
541 #[test]
542 fn a_server_named_elsewhere_is_refused_and_nothing_kept() {
543 let (fake, files) = stand_in("elsewhere");
544 let (other, other_files) = stand_in("elsewhere-other");
545 let base = address_of(&fake.base).unwrap();
546 fake.signing.lock().unwrap().server = other.base.clone();
548 let flow = begin(&base, "Sioul (test)").unwrap();
549 fake.grant_flows();
550 let refused = poll(&flow).unwrap_err();
551 assert_eq!(refused, Refusal::Elsewhere(crate::remote::host_of(&base), crate::remote::host_of(&other.base)));
552 assert!(fake.app_passwords.lock().unwrap().is_empty(), "given back");
553 assert!(other.seen().is_empty(), "nothing asked of the other server");
554 fake.signing.lock().unwrap().server = format!("https://{}", crate::remote::host_of(&base));
556 let flow = begin(&base, "Sioul (test)").unwrap();
557 fake.grant_flows();
558 assert_eq!(poll(&flow).unwrap_err(), Refusal::NotHttps(crate::remote::host_of(&base)), "its own host on another scheme");
559 fake.signing.lock().unwrap().server = "https://cloud.example.org".into();
561 let flow = begin(&base, "Sioul (test)").unwrap();
562 fake.grant_flows();
563 assert!(matches!(poll(&flow), Err(Refusal::Elsewhere(_, named)) if named == "cloud.example.org"));
564 fake.signing.lock().unwrap().server.clear();
566 fake.signing.lock().unwrap().endpoint = format!("{}/login/v2/poll", other.base);
567 assert!(matches!(begin(&base, "Sioul (test)"), Err(Refusal::Elsewhere(_, _))));
568 assert!(other.seen().is_empty());
569 let none = crate::dav::stand_in::serve(Arc::new(Nothing));
571 assert_eq!(begin(&none, "Sioul (test)").unwrap_err(), Refusal::NotNextcloud(crate::remote::host_of(&none)));
572 assert_eq!(begin("http://cloud.example.org", "Sioul (test)").unwrap_err(), Refusal::NotHttps("cloud.example.org".into()));
574 let _ = std::fs::remove_dir_all(files);
575 let _ = std::fs::remove_dir_all(other_files);
576 }
577
578 struct Nothing;
580 impl crate::dav::stand_in::Server for Nothing {
581 fn misbehaves(&self, _: &crate::dav::stand_in::Request) -> Option<crate::dav::stand_in::Fault> {
582 None
583 }
584 fn answer(&self, _: &crate::dav::stand_in::Request) -> crate::dav::stand_in::Reply {
585 crate::dav::stand_in::Reply::new(404, "")
586 }
587 }
588
589 #[test]
592 fn the_app_password_never_leaves_its_server() {
593 use crate::dav::stand_in::{Reply, Request, Server as StandIn, serve};
594 struct Noted(std::sync::Mutex<Vec<String>>);
595 impl StandIn for Noted {
596 fn misbehaves(&self, _: &Request) -> Option<crate::dav::stand_in::Fault> {
597 None
598 }
599 fn answer(&self, request: &Request) -> Reply {
600 self.0.lock().unwrap().push(format!("{} {}", request.method, request.path));
601 Reply::new(404, "")
602 }
603 }
604 struct Pointing(String);
605 impl StandIn for Pointing {
606 fn misbehaves(&self, _: &Request) -> Option<crate::dav::stand_in::Fault> {
607 None
608 }
609 fn answer(&self, _: &Request) -> Reply {
610 Reply::new(207, format!(r#"<?xml version="1.0" encoding="utf-8"?><d:multistatus xmlns:d="DAV:"><d:response><d:href>/remote.php/dav/</d:href><d:propstat><d:prop><d:current-user-principal><d:href>{}</d:href></d:current-user-principal></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>"#, self.0))
611 }
612 }
613 let noted = Arc::new(Noted(std::sync::Mutex::new(Vec::new())));
614 let there = serve(noted.clone());
615 let here = serve(Arc::new(Pointing(format!("{there}/remote.php/dav/principals/users/jane/"))));
616 let grant = Grant::of(&here, "jane@elsewhere.example", "app-password");
618 assert!(homes(&grant).is_err());
619 assert!(noted.0.lock().unwrap().is_empty(), "{:?}", noted.0.lock().unwrap());
620 }
621}