1use std::net::SocketAddr;
39use std::sync::Arc;
40
41use tacenta_discovery::{ServiceDocument, Tls, host_of};
42use tacenta_transport::ClientTls;
43
44use crate::dial::{Connector, Dialer};
45use crate::{AccountConfig, DefaultClient, Error, Result, SecureStore};
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct Endpoints {
50 pub directory: SocketAddr,
51 pub relay: SocketAddr,
52 pub accounts: SocketAddr,
53 pub provisioning: SocketAddr,
54}
55
56#[derive(Clone)]
59pub struct Tacenta {
60 api_key: String,
61 endpoints: Endpoints,
62 trust: Option<(String, ClientTls)>,
65 origin_tls: ClientTls,
69 carriage: Carriage,
70}
71
72#[derive(Clone)]
75enum Carriage {
76 Tcp,
78 Offered(String),
80 Active(String),
82 Custom(Arc<dyn Connector>),
84}
85
86impl std::fmt::Debug for Carriage {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 match self {
89 Carriage::Tcp => f.write_str("Tcp"),
90 Carriage::Offered(b) => f.debug_tuple("Offered").field(b).finish(),
91 Carriage::Active(b) => f.debug_tuple("Active").field(b).finish(),
92 Carriage::Custom(_) => f.write_str("Custom"),
93 }
94 }
95}
96
97impl std::fmt::Debug for Tacenta {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.debug_struct("Tacenta")
103 .field("server_name", &self.server_name())
104 .field("endpoints", &self.endpoints)
105 .field("tls", &self.is_tls())
106 .field("carriage", &self.carriage)
107 .field("api_key", &"<redacted>")
108 .finish()
109 }
110}
111
112impl Tacenta {
113 pub const DEFAULT_SERVER: &'static str = "tacenta.com";
115
116 #[cfg(not(target_arch = "wasm32"))]
119 pub async fn connect(api_key: &str) -> Result<Tacenta> {
120 Tacenta::connect_to(api_key, Tacenta::DEFAULT_SERVER).await
121 }
122
123 #[cfg(not(target_arch = "wasm32"))]
126 pub async fn connect_to(api_key: &str, server: &str) -> Result<Tacenta> {
127 let url = format!("https://{server}{}", tacenta_discovery::WELL_KNOWN_PATH);
128 Tacenta::connect_via(api_key, &url, &ClientTls::web_pki()).await
129 }
130
131 #[cfg(not(target_arch = "wasm32"))]
137 pub async fn connect_via(api_key: &str, url: &str, tls: &ClientTls) -> Result<Tacenta> {
138 let doc = Tacenta::fetch_document(url, tls).await?;
139 Tacenta::from_discovered(api_key, url, &doc, tls).await
140 }
141
142 #[cfg(not(target_arch = "wasm32"))]
146 pub async fn fetch_document(url: &str, tls: &ClientTls) -> Result<ServiceDocument> {
147 let body = tacenta_transport::http_get(url, tls)
148 .await
149 .map_err(|e| Error::Discovery(format!("{}: {e}", shown(url))))?;
150 serde_json::from_slice(&body)
151 .map_err(|e| Error::Discovery(format!("{}: not a service document: {e}", shown(url))))
152 }
153
154 pub async fn from_discovered(
159 api_key: &str,
160 url: &str,
161 doc: &ServiceDocument,
162 tls: &ClientTls,
163 ) -> Result<Tacenta> {
164 check_against_origin(url, doc)?;
165 Tacenta::from_document(api_key, doc, tls).await
166 }
167
168 pub async fn from_document(
173 api_key: &str,
174 doc: &ServiceDocument,
175 tls: &ClientTls,
176 ) -> Result<Tacenta> {
177 if doc.version != tacenta_discovery::VERSION {
178 return Err(Error::Discovery(format!(
179 "service document version {} is not one this client reads",
180 doc.version
181 )));
182 }
183 let trust = match &doc.tls {
184 Tls::WebPki => Some((doc.server_name.clone(), tls.clone())),
186 other => tacenta_transport::trust_for(other, &doc.server_name)
187 .map_err(|e| Error::Discovery(format!("private trust anchors: {e}")))?,
188 };
189 let (directory, relay, accounts, provisioning) = tokio::try_join!(
190 resolve(&doc.directory),
191 resolve(&doc.relay),
192 resolve(&doc.accounts),
193 resolve(&doc.provisioning),
194 )?;
195 Ok(Tacenta {
196 api_key: api_key.to_owned(),
197 endpoints: Endpoints {
198 directory,
199 relay,
200 accounts,
201 provisioning,
202 },
203 trust,
204 origin_tls: tls.clone(),
205 carriage: match &doc.ws {
206 Some(base) => Carriage::Offered(base.trim_end_matches('/').to_owned()),
207 None => Carriage::Tcp,
208 },
209 })
210 }
211
212 pub fn websocket(mut self) -> Result<Tacenta> {
217 self.carriage = match self.carriage {
218 Carriage::Offered(base) | Carriage::Active(base) => Carriage::Active(base),
219 Carriage::Tcp | Carriage::Custom(_) => {
220 return Err(Error::Discovery(
221 "the service document offers no websocket carriage".to_owned(),
222 ));
223 }
224 };
225 Ok(self)
226 }
227
228 pub fn with_connector(mut self, connector: Arc<dyn Connector>) -> Tacenta {
233 self.carriage = Carriage::Custom(connector);
234 self
235 }
236
237 pub fn is_websocket(&self) -> bool {
239 matches!(self.carriage, Carriage::Active(_))
240 }
241
242 pub fn websocket_url(&self) -> Option<&str> {
244 match &self.carriage {
245 Carriage::Offered(base) | Carriage::Active(base) => Some(base),
246 Carriage::Tcp | Carriage::Custom(_) => None,
247 }
248 }
249
250 pub fn from_endpoints(
255 api_key: &str,
256 endpoints: Endpoints,
257 trust: Option<(&str, &ClientTls)>,
258 ) -> Tacenta {
259 Tacenta {
260 api_key: api_key.to_owned(),
261 endpoints,
262 trust: trust.map(|(name, tls)| (name.to_owned(), tls.clone())),
263 origin_tls: ClientTls::web_pki(),
264 carriage: Carriage::Tcp,
265 }
266 }
267
268 pub fn api_key(&self) -> &str {
270 &self.api_key
271 }
272
273 pub fn server_name(&self) -> Option<&str> {
276 self.trust.as_ref().map(|(name, _)| name.as_str())
277 }
278
279 pub fn endpoints(&self) -> &Endpoints {
281 &self.endpoints
282 }
283
284 pub fn is_tls(&self) -> bool {
286 self.trust.is_some()
287 }
288
289 fn dialer(&self) -> Dialer {
290 match &self.carriage {
291 Carriage::Active(base) => Dialer::WebSocket {
292 base: base.clone(),
293 tls: self.origin_tls.clone(),
297 },
298 Carriage::Custom(connector) => Dialer::Custom(connector.clone()),
299 _ => Dialer::Tcp {
300 trust: self.trust.clone(),
301 },
302 }
303 }
304
305 pub async fn sign_up(&self, username: &str, password: &str) -> Result<()> {
307 DefaultClient::sign_up_trusting(
308 self.endpoints.accounts,
309 &self.dialer(),
310 &self.api_key,
311 username,
312 password,
313 )
314 .await
315 }
316
317 pub async fn sign_in(&self, username: &str, password: &str) -> Result<DefaultClient> {
322 self.sign_in_device(username, password, 1).await
323 }
324
325 pub async fn sign_in_device(
327 &self,
328 username: &str,
329 password: &str,
330 device: u8,
331 ) -> Result<DefaultClient> {
332 let config = self.account_config(username, password, device);
333 DefaultClient::sign_in_trusting(&config, &self.dialer()).await
334 }
335
336 pub async fn sign_in_with_state(
339 &self,
340 username: &str,
341 password: &str,
342 device: u8,
343 state: &[u8],
344 ) -> Result<DefaultClient> {
345 let config = self.account_config(username, password, device);
346 DefaultClient::sign_in_with_state_trusting(&config, &self.dialer(), state).await
347 }
348
349 pub async fn sign_in_with_state_sealed(
355 &self,
356 username: &str,
357 password: &str,
358 device: u8,
359 state: &[u8],
360 store: Arc<dyn SecureStore + Send + Sync>,
361 ) -> Result<DefaultClient> {
362 let config = self.account_config(username, password, device);
363 DefaultClient::sign_in_with_state_sealed_trusting(&config, &self.dialer(), state, store)
364 .await
365 }
366
367 fn account_config(&self, username: &str, password: &str, device: u8) -> AccountConfig {
368 AccountConfig {
369 directory: self.endpoints.directory,
370 relay: self.endpoints.relay,
371 accounts: self.endpoints.accounts,
372 provisioning: self.endpoints.provisioning,
373 api_key: self.api_key.clone(),
374 identifier: username.to_owned(),
375 password: password.to_owned(),
376 device,
377 }
378 }
379}
380
381fn shown(s: &str) -> String {
389 let mut t: String = s.chars().take(200).collect();
390 if t.len() < s.len() {
391 t.push('…');
392 }
393 format!("{t:?}")
394}
395
396fn check_against_origin(url: &str, doc: &ServiceDocument) -> Result<()> {
397 let refuse = |m: String| Err(Error::Discovery(format!("{}: {m}", shown(url))));
398 let Some(rest) = url.strip_prefix("https://") else {
399 let authority = url
400 .strip_prefix("http://")
401 .and_then(|r| r.split('/').next())
402 .unwrap_or("");
403 if is_loopback(host_of(authority)) {
404 return Ok(());
405 }
406 return refuse(
407 "plaintext discovery is accepted from loopback only; use https, or build \
408 the handle from a document you vouch for with Tacenta::from_document"
409 .to_owned(),
410 );
411 };
412 let authority = rest.split('/').next().unwrap_or("");
413 let origin = host_of(authority);
414 if doc.tls == Tls::None {
415 return refuse(
416 "the document offers plaintext services, but was fetched over https; \
417 refusing the downgrade"
418 .to_owned(),
419 );
420 }
421 let names = doc
422 .endpoints()
423 .into_iter()
424 .map(host_of)
425 .chain(std::iter::once(doc.server_name.as_str()));
426 for name in names {
427 if !same_site(name, origin) {
428 return refuse(format!(
429 "the document names {name}, which is not {origin} or a subdomain of it"
430 ));
431 }
432 }
433 if let Some(ws) = &doc.ws {
437 let Some(rest) = ws.strip_prefix("wss://") else {
438 return refuse(format!(
439 "the document offers the carriage at {ws}; over https it must be wss://"
440 ));
441 };
442 let ws_host = host_of(rest.split('/').next().unwrap_or(""));
443 if !same_site(ws_host, origin) {
444 return refuse(format!(
445 "the document offers the carriage at {ws_host}, which is not {origin} or a subdomain of it"
446 ));
447 }
448 }
449 Ok(())
450}
451
452fn is_loopback(host: &str) -> bool {
454 host.eq_ignore_ascii_case("localhost")
455 || host
456 .parse::<std::net::IpAddr>()
457 .is_ok_and(|ip| ip.is_loopback())
458}
459
460fn same_site(name: &str, origin: &str) -> bool {
462 let name = name.trim_end_matches('.').to_ascii_lowercase();
463 let origin = origin.trim_end_matches('.').to_ascii_lowercase();
464 name == origin || name.ends_with(&format!(".{origin}"))
465}
466
467#[cfg(not(target_arch = "wasm32"))]
469async fn resolve(hostport: &str) -> Result<SocketAddr> {
470 let mut addrs = tokio::net::lookup_host(hostport)
471 .await
472 .map_err(|e| Error::Discovery(format!("cannot resolve {}: {e}", shown(hostport))))?;
473 addrs
474 .next()
475 .ok_or_else(|| Error::Discovery(format!("no address for {}", shown(hostport))))
476}
477
478#[cfg(target_arch = "wasm32")]
481async fn resolve(hostport: &str) -> Result<SocketAddr> {
482 if let Ok(addr) = hostport.parse::<SocketAddr>() {
483 return Ok(addr);
484 }
485 let port: u16 = hostport
486 .rsplit(':')
487 .next()
488 .and_then(|p| p.parse().ok())
489 .ok_or_else(|| Error::Discovery(format!("no port in {}", shown(hostport))))?;
490 Ok(SocketAddr::from(([0, 0, 0, 0], port)))
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 fn loopback() -> Endpoints {
498 Endpoints {
499 directory: "127.0.0.1:1".parse().unwrap(),
500 relay: "127.0.0.1:2".parse().unwrap(),
501 accounts: "127.0.0.1:3".parse().unwrap(),
502 provisioning: "127.0.0.1:4".parse().unwrap(),
503 }
504 }
505
506 #[test]
507 fn debug_output_redacts_the_api_key() {
508 let tls = ClientTls::web_pki();
509 let t = Tacenta::from_endpoints(
510 "tct_secret_value",
511 loopback(),
512 Some(("tacenta.example", &tls)),
513 );
514 let shown = format!("{t:?}");
515 assert!(!shown.contains("tct_secret_value"), "{shown}");
516 assert!(
517 shown.contains("redacted") && shown.contains("tacenta.example"),
518 "{shown}"
519 );
520 assert_eq!(t.server_name(), Some("tacenta.example"));
521 let plain = Tacenta::from_endpoints("tct_x", loopback(), None);
522 assert_eq!(plain.server_name(), None);
523 assert!(!plain.is_tls());
524 }
525
526 #[tokio::test]
527 async fn an_unknown_version_is_refused() {
528 let mut doc = ServiceDocument::local("127.0.0.1");
529 doc.version = 2;
530 assert!(matches!(
531 Tacenta::from_document("tct_x", &doc, &ClientTls::web_pki()).await,
532 Err(Error::Discovery(_))
533 ));
534 doc.version = 1;
535 let t = Tacenta::from_document("tct_x", &doc, &ClientTls::web_pki())
536 .await
537 .unwrap();
538 assert!(!t.is_tls());
539 assert_eq!(t.endpoints().accounts, "127.0.0.1:4722".parse().unwrap());
540 }
541
542 #[tokio::test]
543 async fn bad_private_trust_anchors_are_a_discovery_error() {
544 let doc = ServiceDocument::on(
545 "127.0.0.1",
546 "dev.internal",
547 [1, 2, 3, 4],
548 Tls::PrivateCa {
549 trust_anchors_pem: "not a certificate".into(),
550 },
551 );
552 let err = Tacenta::from_document("tct_x", &doc, &ClientTls::web_pki())
553 .await
554 .unwrap_err();
555 assert!(
556 matches!(&err, Error::Discovery(m) if m.contains("private trust anchors")),
557 "{err}"
558 );
559 }
560
561 #[test]
562 fn an_https_document_may_not_turn_tls_off() {
563 let url = "https://tacenta.example/.well-known/tacenta";
564 let err =
565 check_against_origin(url, &ServiceDocument::local("tacenta.example")).unwrap_err();
566 assert!(err.to_string().contains("plaintext"), "{err}");
567 }
568
569 #[test]
570 fn plaintext_discovery_is_loopback_only() {
571 for url in [
573 "http://127.0.0.1:4780/.well-known/tacenta",
574 "http://localhost:4780/.well-known/tacenta",
575 "http://[::1]:4780/.well-known/tacenta",
576 ] {
577 check_against_origin(url, &ServiceDocument::local("127.0.0.1")).unwrap();
578 check_against_origin(url, &ServiceDocument::hosted("anything.example")).unwrap();
580 }
581 let err = check_against_origin(
584 "http://10.0.0.5:4780/.well-known/tacenta",
585 &ServiceDocument::local("10.0.0.5"),
586 )
587 .unwrap_err();
588 assert!(err.to_string().contains("from_document"), "{err}");
589 assert!(
590 check_against_origin(
591 "http://gateway.internal/.well-known/tacenta",
592 &ServiceDocument::local("gateway.internal")
593 )
594 .is_err()
595 );
596 }
597
598 #[test]
599 fn an_https_document_may_not_name_another_domain() {
600 let url = "https://tacenta.example/.well-known/tacenta";
601 check_against_origin(url, &ServiceDocument::hosted("tacenta.example")).unwrap();
602 check_against_origin(url, &ServiceDocument::hosted("Relay.Tacenta.Example")).unwrap();
603 let split = ServiceDocument::on(
605 "svc.tacenta.example",
606 "tacenta.example",
607 [1, 2, 3, 4],
608 Tls::WebPki,
609 );
610 check_against_origin(url, &split).unwrap();
611
612 let err =
613 check_against_origin(url, &ServiceDocument::hosted("attacker.example")).unwrap_err();
614 assert!(err.to_string().contains("attacker.example"), "{err}");
615 let err =
616 check_against_origin(url, &ServiceDocument::hosted("nottacenta.example")).unwrap_err();
617 assert!(err.to_string().contains("nottacenta.example"), "{err}");
618 let mut name_only = ServiceDocument::hosted("tacenta.example");
619 name_only.server_name = "attacker.example".into();
620 assert!(check_against_origin(url, &name_only).is_err());
621 check_against_origin(
623 "https://[2001:db8::1]:4780/x",
624 &ServiceDocument::on("2001:db8::1", "2001:db8::1", [1, 2, 3, 4], Tls::WebPki),
625 )
626 .unwrap();
627 assert!(
628 check_against_origin("https://10.0.0.1/x", &ServiceDocument::hosted("10.0.0.2"))
629 .is_err()
630 );
631 }
632
633 #[test]
634 fn the_carriage_is_held_to_the_origin_and_must_be_wss() {
635 let url = "https://tacenta.example/.well-known/tacenta";
636 let hosted = || ServiceDocument::hosted("tacenta.example");
637 check_against_origin(url, &hosted().with_ws("wss://tacenta.example/v1/ws")).unwrap();
638 check_against_origin(url, &hosted().with_ws("wss://ws.tacenta.example/v1/ws")).unwrap();
639 let err = check_against_origin(url, &hosted().with_ws("wss://attacker.example/v1/ws"))
640 .unwrap_err();
641 assert!(err.to_string().contains("attacker.example"), "{err}");
642 let err = check_against_origin(url, &hosted().with_ws("ws://tacenta.example:4780/v1/ws"))
643 .unwrap_err();
644 assert!(err.to_string().contains("must be wss"), "{err}");
645 }
646
647 #[tokio::test]
648 async fn the_carriage_is_offered_then_taken_and_its_slash_trimmed() {
649 let doc: ServiceDocument = serde_json::from_str(
650 r#"{"version":1,"server_name":"127.0.0.1","directory":"127.0.0.1:1","relay":"127.0.0.1:2","accounts":"127.0.0.1:3","provisioning":"127.0.0.1:4","tls":"none","ws":"ws://127.0.0.1:4780/v1/ws/"}"#,
651 )
652 .unwrap();
653 let t = Tacenta::from_document("tct_x", &doc, &ClientTls::web_pki())
654 .await
655 .unwrap();
656 assert!(!t.is_websocket());
657 assert_eq!(t.websocket_url(), Some("ws://127.0.0.1:4780/v1/ws"));
658 let t = t.websocket().unwrap();
659 assert!(t.is_websocket());
660 assert!(
661 matches!(t.dialer(), Dialer::WebSocket { base, .. } if base == "ws://127.0.0.1:4780/v1/ws")
662 );
663 let plain = Tacenta::from_document(
664 "tct_x",
665 &ServiceDocument::local("127.0.0.1"),
666 &ClientTls::web_pki(),
667 )
668 .await
669 .unwrap();
670 assert!(plain.websocket().is_err());
671 }
672}