1#[derive(Clone, serde::Deserialize, serde::Serialize)]
2pub struct AllowedOnePasswordRef {
3 pub id: String,
4 pub reference: String,
5}
6
7#[derive(Clone, Copy, serde::Deserialize, serde::Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum OAuthTokenMode {
10 Cached,
11 Refresh,
12}
13
14#[cfg(unix)]
15mod imp {
16 use std::collections::HashMap;
17 use std::env;
18 use std::fs;
19 use std::io::{self, BufRead, BufReader, Write};
20 #[cfg(target_os = "macos")]
21 use std::os::unix::ffi::OsStringExt;
22 #[cfg(any(target_os = "linux", target_os = "macos"))]
23 use std::os::unix::fs::MetadataExt;
24 use std::os::unix::fs::PermissionsExt;
25 #[cfg(any(target_os = "linux", target_os = "macos"))]
26 use std::os::unix::io::AsRawFd;
27 use std::os::unix::net::{UnixListener, UnixStream};
28 use std::path::{Path, PathBuf};
29 use std::process::{Command, Stdio};
30 use std::thread;
31 use std::time::{Duration, Instant};
32
33 use reqwest::blocking::Client;
34 use serde::{Deserialize, Serialize};
35
36 use crate::error::ViaError;
37 use crate::redaction::Redactor;
38 use crate::secrets::SecretValue;
39
40 const CONNECT_WAIT: Duration = Duration::from_secs(2);
41 const CONNECT_POLL: Duration = Duration::from_millis(50);
42 const IDLE_TIMEOUT: Duration = Duration::from_secs(15 * 60);
43
44 pub fn resolve_onepassword_secret(
45 config_hash: &str,
46 ref_id: &str,
47 ttl_seconds: u64,
48 ) -> Result<SecretValue, ViaError> {
49 let span = crate::timing::span("1password daemon resolve");
50 let response = match request_with_autostart(DaemonRequest::Resolve {
51 config_hash: config_hash.to_owned(),
52 ref_id: ref_id.to_owned(),
53 ttl_seconds,
54 }) {
55 Ok(response) => {
56 span.finish(format!(
57 "cache={}",
58 response.cache.as_deref().unwrap_or("unknown")
59 ));
60 response
61 }
62 Err(error) => {
63 span.finish("failed");
64 return Err(error);
65 }
66 };
67
68 if response.ok {
69 return response
70 .value
71 .ok_or_else(|| ViaError::InvalidConfig("daemon returned no secret".to_owned()));
72 }
73
74 Err(ViaError::ExternalCommandFailed {
75 program: "via daemon".to_owned(),
76 status: None,
77 stderr: response
78 .error
79 .unwrap_or_else(|| "failed to resolve secret".to_owned()),
80 })
81 }
82
83 pub fn register_onepassword_refs(
84 config_hash: &str,
85 account: Option<&str>,
86 refs: Vec<super::AllowedOnePasswordRef>,
87 ) -> Result<(), ViaError> {
88 let response = request_with_autostart(DaemonRequest::Register {
89 config_hash: config_hash.to_owned(),
90 account: account.map(str::to_owned),
91 refs,
92 })?;
93 if response.ok {
94 Ok(())
95 } else {
96 Err(daemon_response_error(
97 response,
98 "failed to register 1Password references",
99 ))
100 }
101 }
102
103 pub fn oauth_access_token(
104 credential: &str,
105 mode: super::OAuthTokenMode,
106 ) -> Result<SecretValue, ViaError> {
107 let span = crate::timing::span("oauth daemon access token");
108 let response = match request_with_autostart(DaemonRequest::OAuthAccessToken {
109 credential: credential.to_owned(),
110 mode,
111 }) {
112 Ok(response) => response,
113 Err(error) => {
114 span.finish("failed");
115 return Err(error);
116 }
117 };
118 span.finish(format!(
119 "cache={}",
120 response.cache.as_deref().unwrap_or("unknown")
121 ));
122
123 oauth_access_token_from_response(response)
124 }
125
126 fn oauth_access_token_from_response(
127 response: ClientDaemonResponse,
128 ) -> Result<SecretValue, ViaError> {
129 if response.ok {
130 return response.value.ok_or_else(|| {
131 ViaError::InvalidConfig("daemon returned no OAuth access token".to_owned())
132 });
133 }
134
135 Err(daemon_response_error(
136 response,
137 "failed to resolve OAuth access token",
138 ))
139 }
140
141 pub fn serve() -> Result<(), ViaError> {
142 let path = socket_path()?;
143 let listener = bind_listener(&path)?;
144 run_server(listener, &path)
145 }
146
147 fn bind_listener(path: &Path) -> Result<UnixListener, ViaError> {
148 prepare_socket_parent(path)?;
149 remove_stale_socket(path)?;
150
151 let listener = UnixListener::bind(path)?;
152 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
153 listener.set_nonblocking(true)?;
154 Ok(listener)
155 }
156
157 fn remove_stale_socket(path: &Path) -> Result<(), ViaError> {
158 if path.exists() {
159 if UnixStream::connect(path).is_ok() {
160 return Err(ViaError::InvalidConfig(
161 "via daemon is already running".to_owned(),
162 ));
163 }
164 fs::remove_file(path)?;
165 }
166
167 Ok(())
168 }
169
170 fn run_server(listener: UnixListener, path: &Path) -> Result<(), ViaError> {
171 let mut state = DaemonState::default();
172 let expected_client = daemon_executable_identity()?;
173 let mut last_activity = Instant::now();
174 loop {
175 match next_server_event(&listener, &mut last_activity)? {
176 ServerEvent::Connection(stream) => {
177 let action = handle_stream(stream, &mut state, expected_client.as_ref());
178 if action == DaemonAction::Stop {
179 break;
180 }
181 }
182 ServerEvent::NoConnection => {}
183 ServerEvent::IdleTimeout => break,
184 }
185 }
186
187 let _ = fs::remove_file(path);
188 Ok(())
189 }
190
191 fn next_server_event(
192 listener: &UnixListener,
193 last_activity: &mut Instant,
194 ) -> Result<ServerEvent, ViaError> {
195 match listener.accept() {
196 Ok((stream, _)) => {
197 *last_activity = Instant::now();
198 Ok(ServerEvent::Connection(stream))
199 }
200 Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
201 wait_for_connection(last_activity)
202 }
203 Err(error) => Err(error.into()),
204 }
205 }
206
207 fn wait_for_connection(last_activity: &Instant) -> Result<ServerEvent, ViaError> {
208 if last_activity.elapsed() >= IDLE_TIMEOUT {
209 Ok(ServerEvent::IdleTimeout)
210 } else {
211 thread::sleep(CONNECT_POLL);
212 Ok(ServerEvent::NoConnection)
213 }
214 }
215
216 pub fn status() -> Result<(), ViaError> {
217 control_request(DaemonRequest::Status, print_status, "status failed")
218 }
219
220 pub fn clear() -> Result<(), ViaError> {
221 control_request(
222 DaemonRequest::Clear,
223 |_| println!("via daemon: cache cleared"),
224 "clear failed",
225 )
226 }
227
228 pub fn stop() -> Result<(), ViaError> {
229 control_request(
230 DaemonRequest::Stop,
231 |_| println!("via daemon: stopped"),
232 "stop failed",
233 )
234 }
235
236 fn control_request(
237 daemon_request: DaemonRequest,
238 print_success: impl FnOnce(&ClientDaemonResponse),
239 fallback_error: &str,
240 ) -> Result<(), ViaError> {
241 match request(daemon_request) {
242 Ok(response) if response.ok => {
243 print_success(&response);
244 Ok(())
245 }
246 Ok(response) => Err(daemon_response_error(response, fallback_error)),
247 Err(error) if daemon_unavailable(&error) => {
248 println!("via daemon: stopped");
249 Ok(())
250 }
251 Err(error) => Err(error),
252 }
253 }
254
255 fn print_status(response: &ClientDaemonResponse) {
256 println!("via daemon: running");
257 println!("cached entries: {}", response.entries.unwrap_or(0));
258 }
259
260 fn daemon_response_error(response: ClientDaemonResponse, fallback: &str) -> ViaError {
261 ViaError::ExternalCommandFailed {
262 program: "via daemon".to_owned(),
263 status: None,
264 stderr: response.error.unwrap_or_else(|| fallback.to_owned()),
265 }
266 }
267
268 fn request_with_autostart(
269 daemon_request: DaemonRequest,
270 ) -> Result<ClientDaemonResponse, ViaError> {
271 match request(daemon_request.clone()) {
272 Ok(response) => Ok(response),
273 Err(error) if daemon_unavailable(&error) => {
274 start_daemon()?;
275 request(daemon_request)
276 }
277 Err(error) => Err(error),
278 }
279 }
280
281 fn request(request: DaemonRequest) -> Result<ClientDaemonResponse, ViaError> {
282 let path = socket_path()?;
283 let mut stream = UnixStream::connect(path)?;
284 let raw = SecretValue::new(serde_json::to_string(&request)?);
285 stream.write_all(raw.expose().as_bytes())?;
286 stream.write_all(b"\n")?;
287
288 let mut line = String::new();
289 BufReader::new(stream).read_line(&mut line)?;
290 if line.trim().is_empty() {
291 return Err(ViaError::InvalidConfig(
292 "daemon returned an empty response".to_owned(),
293 ));
294 }
295 let line = SecretValue::new(line);
296
297 serde_json::from_str(line.expose()).map_err(Into::into)
298 }
299
300 fn start_daemon() -> Result<(), ViaError> {
301 let exe = env::current_exe()?;
302 let mut command = Command::new(exe);
303 command
304 .arg("daemon")
305 .arg("serve")
306 .stdin(Stdio::null())
307 .stdout(Stdio::null());
308 if crate::timing::enabled() {
309 command.stderr(Stdio::inherit());
310 } else {
311 command.stderr(Stdio::null());
312 }
313 command.spawn()?;
314
315 let started = Instant::now();
316 while started.elapsed() < CONNECT_WAIT {
317 if UnixStream::connect(socket_path()?).is_ok() {
318 return Ok(());
319 }
320 thread::sleep(CONNECT_POLL);
321 }
322
323 Err(ViaError::InvalidConfig(
324 "timed out waiting for via daemon to start".to_owned(),
325 ))
326 }
327
328 fn handle_stream(
329 mut stream: UnixStream,
330 state: &mut DaemonState,
331 expected_client: Option<&ExecutableIdentity>,
332 ) -> DaemonAction {
333 let response = match verify_peer_executable(&stream, expected_client) {
334 Ok(()) => handle_verified_stream(&mut stream, state),
335 Err(error) => {
336 DaemonResponseInternal::error(format!("daemon client verification failed: {error}"))
337 }
338 };
339 let action = if response.stop {
340 DaemonAction::Stop
341 } else {
342 DaemonAction::Continue
343 };
344
345 write_daemon_response(&mut stream, response);
346
347 action
348 }
349
350 fn handle_verified_stream(
351 stream: &mut UnixStream,
352 state: &mut DaemonState,
353 ) -> DaemonResponseInternal {
354 let mut line = String::new();
355 let mut reader = BufReader::new(stream);
356 match reader.read_line(&mut line) {
357 Ok(_) => {
358 let line = SecretValue::new(line);
359 match serde_json::from_str(line.expose()) {
360 Ok(request) => state.handle(request),
361 Err(error) => {
362 DaemonResponseInternal::error(format!("invalid daemon request: {error}"))
363 }
364 }
365 }
366 Err(error) => {
367 DaemonResponseInternal::error(format!("failed to read daemon request: {error}"))
368 }
369 }
370 }
371
372 fn write_daemon_response(stream: &mut UnixStream, response: DaemonResponseInternal) {
373 if let Ok(raw) = serde_json::to_string(&response.into_public()) {
374 let raw = SecretValue::new(raw);
375 let _ = stream.write_all(raw.expose().as_bytes());
376 let _ = stream.write_all(b"\n");
377 }
378 }
379
380 #[derive(Clone, Deserialize, Serialize)]
381 #[serde(tag = "type", rename_all = "snake_case")]
382 enum DaemonRequest {
383 Register {
384 config_hash: String,
385 account: Option<String>,
386 refs: Vec<super::AllowedOnePasswordRef>,
387 },
388 Resolve {
389 config_hash: String,
390 ref_id: String,
391 ttl_seconds: u64,
392 },
393 OAuthAccessToken {
394 credential: String,
395 #[serde(default = "default_oauth_token_mode")]
396 mode: super::OAuthTokenMode,
397 },
398 Clear,
399 Status,
400 Stop,
401 }
402
403 fn default_oauth_token_mode() -> super::OAuthTokenMode {
404 super::OAuthTokenMode::Cached
405 }
406
407 #[derive(Default)]
408 struct DaemonState {
409 cache: HashMap<SecretCacheKey, SecretCacheEntry>,
410 oauth_cache: HashMap<String, crate::auth::oauth::CachedOAuthToken>,
411 registrations: HashMap<String, RegisteredConfig>,
412 }
413
414 impl DaemonState {
415 fn handle(&mut self, request: DaemonRequest) -> DaemonResponseInternal {
416 self.prune_expired();
417
418 match request {
419 DaemonRequest::Register {
420 config_hash,
421 account,
422 refs,
423 } => self.register(config_hash, account, refs),
424 DaemonRequest::Resolve {
425 config_hash,
426 ref_id,
427 ttl_seconds,
428 } => self.resolve(config_hash, ref_id, ttl_seconds),
429 DaemonRequest::OAuthAccessToken { credential, mode } => {
430 self.oauth_access_token(&credential, mode)
431 }
432 DaemonRequest::Clear => {
433 self.cache.clear();
434 self.oauth_cache.clear();
435 self.registrations.clear();
436 DaemonResponseInternal::ok()
437 }
438 DaemonRequest::Status => {
439 let mut response = DaemonResponseInternal::ok();
440 response.entries = Some(self.cache.len() + self.oauth_cache.len());
441 response
442 }
443 DaemonRequest::Stop => {
444 let mut response = DaemonResponseInternal::ok();
445 response.stop = true;
446 response
447 }
448 }
449 }
450
451 fn register(
452 &mut self,
453 config_hash: String,
454 account: Option<String>,
455 refs: Vec<super::AllowedOnePasswordRef>,
456 ) -> DaemonResponseInternal {
457 if config_hash.trim().is_empty() {
458 return DaemonResponseInternal::error("config hash must not be empty");
459 }
460
461 let refs = match normalize_allowed_refs(refs) {
462 Ok(refs) => refs,
463 Err(error) => return DaemonResponseInternal::error(error),
464 };
465 self.registrations
466 .insert(config_hash, RegisteredConfig { account, refs });
467 DaemonResponseInternal::ok()
468 }
469
470 fn resolve(
471 &mut self,
472 config_hash: String,
473 ref_id: String,
474 ttl_seconds: u64,
475 ) -> DaemonResponseInternal {
476 let Some(secret) = self.allowed_secret(&config_hash, &ref_id) else {
477 return DaemonResponseInternal::error(
478 "secret reference is not registered for this config",
479 );
480 };
481 let key = SecretCacheKey {
482 config_hash,
483 ref_id,
484 };
485 if let Some(entry) = self.cache.get(&key) {
486 let mut response = DaemonResponseInternal::ok();
487 response.value = Some(entry.value.clone());
488 response.cache = Some("hit".to_owned());
489 return response;
490 }
491
492 match op_read(secret.account.as_deref(), &secret.reference) {
493 Ok(value) => {
494 let ttl = Duration::from_secs(ttl_seconds.max(1));
495 let response_value = value.clone();
496 self.cache.insert(
497 key,
498 SecretCacheEntry {
499 value,
500 expires_at: Instant::now() + ttl,
501 },
502 );
503 let mut response = DaemonResponseInternal::ok();
504 response.value = Some(response_value);
505 response.cache = Some("miss".to_owned());
506 response
507 }
508 Err(error) => DaemonResponseInternal::error(error),
509 }
510 }
511
512 fn allowed_secret(&self, config_hash: &str, ref_id: &str) -> Option<AllowedSecret> {
513 let registration = self.registrations.get(config_hash)?;
514 let reference = registration.refs.get(ref_id)?;
515 Some(AllowedSecret {
516 account: registration.account.clone(),
517 reference: reference.clone(),
518 })
519 }
520
521 fn oauth_access_token(
522 &mut self,
523 credential: &str,
524 mode: super::OAuthTokenMode,
525 ) -> DaemonResponseInternal {
526 let bundle = match crate::auth::oauth::CredentialBundle::parse(credential) {
527 Ok(bundle) => bundle,
528 Err(error) => return DaemonResponseInternal::error(error.to_string()),
529 };
530 let key = crate::auth::oauth::cache_key(&bundle);
531 let now = match crate::auth::oauth::unix_timestamp() {
532 Ok(now) => now,
533 Err(error) => return DaemonResponseInternal::error(error.to_string()),
534 };
535
536 if matches!(mode, super::OAuthTokenMode::Cached) {
537 if let Some(access_token) =
538 crate::auth::oauth::cached_access_token(self.oauth_cache.get(&key), now)
539 {
540 let mut response = DaemonResponseInternal::ok();
541 response.value = Some(SecretValue::new(access_token));
542 response.cache = Some("hit".to_owned());
543 return response;
544 }
545 }
546
547 let cached = self.oauth_cache.get(&key).cloned();
548 let mut redactor = Redactor::new();
549 redactor.add(credential);
550 crate::auth::oauth::register_bundle_secrets(&bundle, &mut redactor);
551 crate::auth::oauth::register_cached_secrets(cached.as_ref(), &mut redactor);
552
553 let client = Client::new();
554 match crate::auth::oauth::exchange_access_token(
555 &client,
556 &bundle,
557 cached.as_ref(),
558 &mut redactor,
559 ) {
560 Ok(token) => {
561 self.oauth_cache.insert(
562 key,
563 crate::auth::oauth::CachedOAuthToken {
564 access_token: token.access_token.clone(),
565 expires_at: token.expires_at,
566 refresh_token: token.refresh_token.clone(),
567 },
568 );
569 let mut response = DaemonResponseInternal::ok();
570 response.value = Some(SecretValue::new(token.access_token));
571 response.cache = Some("miss".to_owned());
572 response
573 }
574 Err(error) => DaemonResponseInternal::error(redactor.redact(&error.to_string())),
575 }
576 }
577
578 fn prune_expired(&mut self) {
579 let now = Instant::now();
580 self.cache.retain(|_, entry| entry.expires_at > now);
581 if let Ok(now) = crate::auth::oauth::unix_timestamp() {
582 self.oauth_cache.retain(|_, entry| {
583 entry.refresh_token.is_some()
584 || crate::auth::oauth::cached_access_token(Some(entry), now).is_some()
585 });
586 }
587 }
588 }
589
590 #[derive(Hash, Eq, PartialEq)]
591 struct SecretCacheKey {
592 config_hash: String,
593 ref_id: String,
594 }
595
596 struct RegisteredConfig {
597 account: Option<String>,
598 refs: HashMap<String, String>,
599 }
600
601 struct AllowedSecret {
602 account: Option<String>,
603 reference: String,
604 }
605
606 struct SecretCacheEntry {
607 value: SecretValue,
608 expires_at: Instant,
609 }
610
611 fn normalize_allowed_refs(
612 refs: Vec<super::AllowedOnePasswordRef>,
613 ) -> Result<HashMap<String, String>, String> {
614 let mut normalized = HashMap::new();
615 for allowed_ref in refs {
616 if allowed_ref.id.trim().is_empty() {
617 return Err("registered secret reference id must not be empty".to_owned());
618 }
619 if !allowed_ref.reference.starts_with("op://") {
620 return Err("registered secret reference must start with op://".to_owned());
621 }
622 normalized.insert(allowed_ref.id, allowed_ref.reference);
623 }
624 Ok(normalized)
625 }
626
627 #[derive(Serialize)]
628 struct WireDaemonResponse {
629 ok: bool,
630 #[serde(
631 skip_serializing_if = "Option::is_none",
632 serialize_with = "serialize_secret_value_option"
633 )]
634 value: Option<SecretValue>,
635 #[serde(skip_serializing_if = "Option::is_none")]
636 cache: Option<String>,
637 #[serde(skip_serializing_if = "Option::is_none")]
638 entries: Option<usize>,
639 #[serde(skip_serializing_if = "Option::is_none")]
640 error: Option<String>,
641 }
642
643 #[derive(Deserialize)]
644 struct ClientDaemonResponse {
645 ok: bool,
646 value: Option<SecretValue>,
647 cache: Option<String>,
648 entries: Option<usize>,
649 error: Option<String>,
650 }
651
652 struct DaemonResponseInternal {
653 ok: bool,
654 value: Option<SecretValue>,
655 cache: Option<String>,
656 entries: Option<usize>,
657 error: Option<String>,
658 stop: bool,
659 }
660
661 impl DaemonResponseInternal {
662 fn ok() -> Self {
663 Self {
664 ok: true,
665 value: None,
666 cache: None,
667 entries: None,
668 error: None,
669 stop: false,
670 }
671 }
672
673 fn error(error: impl Into<String>) -> Self {
674 Self {
675 ok: false,
676 value: None,
677 cache: None,
678 entries: None,
679 error: Some(error.into()),
680 stop: false,
681 }
682 }
683
684 fn into_public(self) -> WireDaemonResponse {
685 WireDaemonResponse {
686 ok: self.ok,
687 value: self.value,
688 cache: self.cache,
689 entries: self.entries,
690 error: self.error,
691 }
692 }
693 }
694
695 fn serialize_secret_value_option<S>(
696 value: &Option<SecretValue>,
697 serializer: S,
698 ) -> Result<S::Ok, S::Error>
699 where
700 S: serde::Serializer,
701 {
702 match value {
703 Some(value) => serializer.serialize_some(value.expose()),
704 None => serializer.serialize_none(),
705 }
706 }
707
708 fn op_read(account: Option<&str>, reference: &str) -> Result<SecretValue, String> {
709 let mut command = Command::new("op");
710 command.arg("read").arg(reference);
711 if let Some(account) = account {
712 command.arg("--account").arg(account);
713 }
714
715 let output = command
716 .output()
717 .map_err(|source| format!("program `op` was not found: {source}"))?;
718
719 if !output.status.success() {
720 return Err(format!(
721 "program `op` failed with status {:?}: {}",
722 output.status.code(),
723 String::from_utf8_lossy(&output.stderr).trim()
724 ));
725 }
726
727 Ok(SecretValue::from_utf8_lossy_trimmed(output.stdout))
728 }
729
730 fn socket_path() -> Result<PathBuf, ViaError> {
731 if let Some(path) = env_path("VIA_DAEMON_SOCKET") {
732 return Ok(path);
733 }
734
735 if let Some(runtime) = env_path("XDG_RUNTIME_DIR") {
736 return Ok(runtime.join("via").join("daemon.sock"));
737 }
738
739 Ok(env::temp_dir()
740 .join(format!("via-{}", user_id()))
741 .join("daemon.sock"))
742 }
743
744 fn prepare_socket_parent(path: &Path) -> Result<(), ViaError> {
745 let parent = path.parent().ok_or_else(|| {
746 ViaError::InvalidConfig("daemon socket path has no parent".to_owned())
747 })?;
748 fs::create_dir_all(parent)?;
749 fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?;
750 Ok(())
751 }
752
753 fn env_path(name: &str) -> Option<PathBuf> {
754 env::var_os(name)
755 .filter(|value| !value.as_os_str().is_empty())
756 .map(PathBuf::from)
757 }
758
759 fn user_id() -> String {
760 env::var("UID")
761 .ok()
762 .filter(|value| !value.trim().is_empty())
763 .unwrap_or_else(|| {
764 env::var("USER")
765 .ok()
766 .map(|value| sanitize_path_part(&value))
767 .filter(|value| !value.is_empty())
768 .unwrap_or_else(|| "unknown".to_owned())
769 })
770 }
771
772 fn sanitize_path_part(value: &str) -> String {
773 value
774 .chars()
775 .filter(|character| character.is_ascii_alphanumeric() || *character == '_')
776 .collect()
777 }
778
779 fn daemon_unavailable(error: &ViaError) -> bool {
780 matches!(error, ViaError::Io(source) if matches!(
781 source.kind(),
782 io::ErrorKind::NotFound
783 | io::ErrorKind::ConnectionRefused
784 | io::ErrorKind::ConnectionReset
785 | io::ErrorKind::BrokenPipe
786 ))
787 }
788
789 #[derive(Clone)]
790 struct ExecutableIdentity {
791 path: PathBuf,
792 device: u64,
793 inode: u64,
794 }
795
796 impl ExecutableIdentity {
797 fn matches(&self, other: &Self) -> bool {
798 self.path == other.path || (self.device == other.device && self.inode == other.inode)
799 }
800 }
801
802 #[cfg(any(target_os = "linux", target_os = "macos"))]
803 fn daemon_executable_identity() -> Result<Option<ExecutableIdentity>, ViaError> {
804 Ok(Some(executable_identity_from_path(&env::current_exe()?)?))
805 }
806
807 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
808 fn daemon_executable_identity() -> Result<Option<ExecutableIdentity>, ViaError> {
809 Ok(None)
810 }
811
812 #[cfg(any(target_os = "linux", target_os = "macos"))]
813 fn verify_peer_executable(
814 stream: &UnixStream,
815 expected: Option<&ExecutableIdentity>,
816 ) -> Result<(), ViaError> {
817 let Some(expected) = expected else {
818 return Ok(());
819 };
820 let peer = peer_executable_identity(stream)?;
821 if expected.matches(&peer) {
822 Ok(())
823 } else {
824 Err(ViaError::InvalidConfig(
825 "daemon refused connection from executable other than via".to_owned(),
826 ))
827 }
828 }
829
830 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
831 fn verify_peer_executable(
832 _stream: &UnixStream,
833 _expected: Option<&ExecutableIdentity>,
834 ) -> Result<(), ViaError> {
835 Ok(())
836 }
837
838 #[cfg(any(target_os = "linux", target_os = "macos"))]
839 fn executable_identity_from_path(path: &Path) -> Result<ExecutableIdentity, ViaError> {
840 let metadata = fs::metadata(path)?;
841 Ok(executable_identity_from_parts(path.to_path_buf(), metadata))
842 }
843
844 #[cfg(any(target_os = "linux", target_os = "macos"))]
845 fn executable_identity_from_parts(path: PathBuf, metadata: fs::Metadata) -> ExecutableIdentity {
846 let path = fs::canonicalize(&path).unwrap_or(path);
847 ExecutableIdentity {
848 path,
849 device: metadata.dev(),
850 inode: metadata.ino(),
851 }
852 }
853
854 #[cfg(target_os = "linux")]
855 fn peer_executable_identity(stream: &UnixStream) -> Result<ExecutableIdentity, ViaError> {
856 let pid = linux_peer_pid(stream)?;
857 let proc_exe = PathBuf::from(format!("/proc/{pid}/exe"));
858 let metadata = fs::metadata(&proc_exe)?;
859 let path = fs::read_link(&proc_exe).unwrap_or_else(|_| proc_exe.clone());
860 Ok(executable_identity_from_parts(path, metadata))
861 }
862
863 #[cfg(target_os = "linux")]
864 fn linux_peer_pid(stream: &UnixStream) -> Result<libc::pid_t, ViaError> {
865 let mut credentials = std::mem::MaybeUninit::<libc::ucred>::uninit();
866 let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
867 let result = unsafe {
870 libc::getsockopt(
871 stream.as_raw_fd(),
872 libc::SOL_SOCKET,
873 libc::SO_PEERCRED,
874 credentials.as_mut_ptr().cast(),
875 &mut length,
876 )
877 };
878 if result != 0 {
879 return Err(io::Error::last_os_error().into());
880 }
881 if length as usize != std::mem::size_of::<libc::ucred>() {
882 return Err(ViaError::InvalidConfig(
883 "daemon could not read peer process credentials".to_owned(),
884 ));
885 }
886
887 Ok(unsafe { credentials.assume_init() }.pid)
889 }
890
891 #[cfg(target_os = "macos")]
892 fn peer_executable_identity(stream: &UnixStream) -> Result<ExecutableIdentity, ViaError> {
893 let pid = macos_peer_pid(stream)?;
894 let mut buffer = vec![0_u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
895 let length =
897 unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
898 if length <= 0 {
899 return Err(io::Error::last_os_error().into());
900 }
901 buffer.truncate(length as usize);
902 let path = PathBuf::from(std::ffi::OsString::from_vec(buffer));
903 executable_identity_from_path(&path)
904 }
905
906 #[cfg(target_os = "macos")]
907 fn macos_peer_pid(stream: &UnixStream) -> Result<libc::pid_t, ViaError> {
908 let mut pid = std::mem::MaybeUninit::<libc::pid_t>::uninit();
909 let mut length = std::mem::size_of::<libc::pid_t>() as libc::socklen_t;
910 let result = unsafe {
913 libc::getsockopt(
914 stream.as_raw_fd(),
915 libc::SOL_LOCAL,
916 libc::LOCAL_PEERPID,
917 pid.as_mut_ptr().cast(),
918 &mut length,
919 )
920 };
921 if result != 0 {
922 return Err(io::Error::last_os_error().into());
923 }
924 if length as usize != std::mem::size_of::<libc::pid_t>() {
925 return Err(ViaError::InvalidConfig(
926 "daemon could not read peer process id".to_owned(),
927 ));
928 }
929
930 Ok(unsafe { pid.assume_init() })
932 }
933
934 #[derive(PartialEq, Eq)]
935 enum DaemonAction {
936 Continue,
937 Stop,
938 }
939
940 enum ServerEvent {
941 Connection(UnixStream),
942 NoConnection,
943 IdleTimeout,
944 }
945
946 #[cfg(test)]
947 mod tests {
948 use super::*;
949 use std::io::{Read, Write};
950 use std::net::TcpListener;
951 use std::thread;
952
953 #[test]
954 fn rejects_unregistered_resolve_request() {
955 let mut state = DaemonState::default();
956
957 let response = state.handle(DaemonRequest::Resolve {
958 config_hash: "config".to_owned(),
959 ref_id: "secret".to_owned(),
960 ttl_seconds: 300,
961 });
962
963 assert!(!response.ok);
964 assert!(response
965 .error
966 .as_deref()
967 .unwrap()
968 .contains("not registered"));
969 }
970
971 #[test]
972 fn rejects_registered_non_op_reference() {
973 let mut state = DaemonState::default();
974
975 let response = state.handle(DaemonRequest::Register {
976 config_hash: "config".to_owned(),
977 account: None,
978 refs: vec![super::super::AllowedOnePasswordRef {
979 id: "secret".to_owned(),
980 reference: "plaintext".to_owned(),
981 }],
982 });
983
984 assert!(!response.ok);
985 assert!(response
986 .error
987 .as_deref()
988 .unwrap()
989 .contains("must start with op://"));
990 }
991
992 #[test]
993 fn resolves_registered_ref_id_from_cache() {
994 let mut state = DaemonState::default();
995 let register = state.handle(DaemonRequest::Register {
996 config_hash: "config".to_owned(),
997 account: None,
998 refs: vec![super::super::AllowedOnePasswordRef {
999 id: "secret".to_owned(),
1000 reference: "op://Private/Example/token".to_owned(),
1001 }],
1002 });
1003 assert!(register.ok);
1004 state.cache.insert(
1005 SecretCacheKey {
1006 config_hash: "config".to_owned(),
1007 ref_id: "secret".to_owned(),
1008 },
1009 SecretCacheEntry {
1010 value: SecretValue::new("cached-secret".to_owned()),
1011 expires_at: Instant::now() + Duration::from_secs(300),
1012 },
1013 );
1014
1015 let response = state.handle(DaemonRequest::Resolve {
1016 config_hash: "config".to_owned(),
1017 ref_id: "secret".to_owned(),
1018 ttl_seconds: 300,
1019 });
1020
1021 assert!(response.ok);
1022 assert_eq!(response.cache.as_deref(), Some("hit"));
1023 assert_eq!(
1024 response.value.as_ref().map(SecretValue::expose),
1025 Some("cached-secret")
1026 );
1027 }
1028
1029 #[test]
1030 fn clear_drops_cached_values_and_registered_refs() {
1031 let mut state = DaemonState::default();
1032 let register = state.handle(DaemonRequest::Register {
1033 config_hash: "config".to_owned(),
1034 account: None,
1035 refs: vec![super::super::AllowedOnePasswordRef {
1036 id: "secret".to_owned(),
1037 reference: "op://Private/Example/token".to_owned(),
1038 }],
1039 });
1040 assert!(register.ok);
1041 state.cache.insert(
1042 SecretCacheKey {
1043 config_hash: "config".to_owned(),
1044 ref_id: "secret".to_owned(),
1045 },
1046 SecretCacheEntry {
1047 value: SecretValue::new("cached-secret".to_owned()),
1048 expires_at: Instant::now() + Duration::from_secs(300),
1049 },
1050 );
1051
1052 let clear = state.handle(DaemonRequest::Clear);
1053 assert!(clear.ok);
1054 let response = state.handle(DaemonRequest::Resolve {
1055 config_hash: "config".to_owned(),
1056 ref_id: "secret".to_owned(),
1057 ttl_seconds: 300,
1058 });
1059
1060 assert!(!response.ok);
1061 assert!(state.cache.is_empty());
1062 assert!(state.oauth_cache.is_empty());
1063 assert!(state.registrations.is_empty());
1064 }
1065
1066 #[test]
1067 fn oauth_access_token_is_cached_in_daemon_memory() {
1068 let response_body = serde_json::json!({
1069 "access_token": "fresh-access-token",
1070 "token_type": "Bearer",
1071 "expires_in": 3600,
1072 "refresh_token": "rotated-refresh-token",
1073 })
1074 .to_string();
1075 let (token_url, server) = token_server(response_body);
1076 let mut state = DaemonState::default();
1077 let credential = serde_json::json!({
1078 "type": "service_oauth",
1079 "token_url": token_url,
1080 "grant_type": "refresh_token",
1081 "client_id": "client-id",
1082 "client_secret": "client-secret",
1083 "refresh_token": "configured-refresh-token",
1084 })
1085 .to_string();
1086
1087 let response = state.handle(DaemonRequest::OAuthAccessToken {
1088 credential: credential.clone(),
1089 mode: crate::daemon::OAuthTokenMode::Cached,
1090 });
1091 let request = server.join().unwrap();
1092 let cached_response = state.handle(DaemonRequest::OAuthAccessToken {
1093 credential,
1094 mode: crate::daemon::OAuthTokenMode::Cached,
1095 });
1096
1097 assert!(response.ok);
1098 assert_eq!(response.cache.as_deref(), Some("miss"));
1099 assert_eq!(
1100 response.value.as_ref().map(SecretValue::expose),
1101 Some("fresh-access-token")
1102 );
1103 assert!(request.contains("grant_type=refresh_token"));
1104 assert!(request.contains("refresh_token=configured-refresh-token"));
1105 assert_eq!(state.oauth_cache.len(), 1);
1106 assert!(cached_response.ok);
1107 assert_eq!(cached_response.cache.as_deref(), Some("hit"));
1108 assert_eq!(
1109 cached_response.value.as_ref().map(SecretValue::expose),
1110 Some("fresh-access-token")
1111 );
1112 }
1113
1114 #[test]
1115 fn oauth_access_token_refresh_mode_skips_unexpired_cache() {
1116 let response_body = serde_json::json!({
1117 "access_token": "fresh-access-token",
1118 "token_type": "Bearer",
1119 "expires_in": 3600,
1120 })
1121 .to_string();
1122 let (token_url, server) = token_server(response_body);
1123 let mut state = DaemonState::default();
1124 let credential = serde_json::json!({
1125 "type": "service_oauth",
1126 "token_url": token_url,
1127 "grant_type": "client_credentials",
1128 "client_id": "client-id",
1129 "client_secret": "client-secret",
1130 "scope": "read,issues:create",
1131 })
1132 .to_string();
1133 let bundle = crate::auth::oauth::CredentialBundle::parse(&credential).unwrap();
1134 state.oauth_cache.insert(
1135 crate::auth::oauth::cache_key(&bundle),
1136 crate::auth::oauth::CachedOAuthToken {
1137 access_token: "cached-access-token".to_owned(),
1138 expires_at: crate::auth::oauth::unix_timestamp().unwrap() + 3_600,
1139 refresh_token: None,
1140 },
1141 );
1142
1143 let response = state.handle(DaemonRequest::OAuthAccessToken {
1144 credential,
1145 mode: crate::daemon::OAuthTokenMode::Refresh,
1146 });
1147 let request = server.join().unwrap();
1148
1149 assert!(response.ok);
1150 assert_eq!(response.cache.as_deref(), Some("miss"));
1151 assert_eq!(
1152 response.value.as_ref().map(SecretValue::expose),
1153 Some("fresh-access-token")
1154 );
1155 assert!(request.contains("grant_type=client_credentials"));
1156 }
1157
1158 #[test]
1159 fn prune_expired_keeps_rotated_oauth_refresh_tokens() {
1160 let mut state = DaemonState::default();
1161 state.oauth_cache.insert(
1162 "oauth".to_owned(),
1163 crate::auth::oauth::CachedOAuthToken {
1164 access_token: "expired-access-token".to_owned(),
1165 expires_at: 0,
1166 refresh_token: Some("rotated-refresh-token".to_owned()),
1167 },
1168 );
1169
1170 state.prune_expired();
1171
1172 assert_eq!(state.oauth_cache.len(), 1);
1173 assert_eq!(
1174 state
1175 .oauth_cache
1176 .values()
1177 .next()
1178 .and_then(|entry| entry.refresh_token.as_deref()),
1179 Some("rotated-refresh-token")
1180 );
1181 }
1182
1183 fn token_server(response_body: String) -> (String, thread::JoinHandle<String>) {
1184 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1185 let address = listener.local_addr().unwrap();
1186 let handle = thread::spawn(move || {
1187 let (mut stream, _) = listener.accept().unwrap();
1188 let mut buffer = [0_u8; 8192];
1189 let read = stream.read(&mut buffer).unwrap();
1190 let request = String::from_utf8_lossy(&buffer[..read]).to_string();
1191 let response = format!(
1192 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1193 response_body.len(),
1194 response_body
1195 );
1196 stream.write_all(response.as_bytes()).unwrap();
1197 request
1198 });
1199
1200 (format!("http://{address}/oauth/token"), handle)
1201 }
1202 }
1203}
1204
1205#[cfg(not(unix))]
1206mod imp {
1207 use crate::error::ViaError;
1208 use crate::secrets::SecretValue;
1209
1210 pub fn resolve_onepassword_secret(
1211 _config_hash: &str,
1212 _ref_id: &str,
1213 _ttl_seconds: u64,
1214 ) -> Result<SecretValue, ViaError> {
1215 Err(ViaError::InvalidConfig(
1216 "via daemon cache is only supported on Unix-like platforms".to_owned(),
1217 ))
1218 }
1219
1220 pub fn register_onepassword_refs(
1221 _config_hash: &str,
1222 _account: Option<&str>,
1223 _refs: Vec<super::AllowedOnePasswordRef>,
1224 ) -> Result<(), ViaError> {
1225 Err(ViaError::InvalidConfig(
1226 "via daemon cache is only supported on Unix-like platforms".to_owned(),
1227 ))
1228 }
1229
1230 pub fn oauth_access_token(
1231 _credential: &str,
1232 _mode: super::OAuthTokenMode,
1233 ) -> Result<SecretValue, ViaError> {
1234 Err(ViaError::InvalidConfig(
1235 "OAuth auth requires the via daemon, which is only supported on Unix-like platforms"
1236 .to_owned(),
1237 ))
1238 }
1239
1240 pub fn serve() -> Result<(), ViaError> {
1241 Err(ViaError::InvalidConfig(
1242 "via daemon cache is only supported on Unix-like platforms".to_owned(),
1243 ))
1244 }
1245
1246 pub fn status() -> Result<(), ViaError> {
1247 println!("via daemon: unsupported");
1248 Ok(())
1249 }
1250
1251 pub fn clear() -> Result<(), ViaError> {
1252 println!("via daemon: unsupported");
1253 Ok(())
1254 }
1255
1256 pub fn stop() -> Result<(), ViaError> {
1257 println!("via daemon: unsupported");
1258 Ok(())
1259 }
1260}
1261
1262pub use imp::{
1263 clear, oauth_access_token, register_onepassword_refs, resolve_onepassword_secret, serve,
1264 status, stop,
1265};