1use std::collections::HashMap;
7use std::io::{self, Read, Write};
8
9use chrono::Utc;
10use serde_json::{json, Value};
11use tsafe_core::{
12 audit::{AuditEntry, AuditLog},
13 errors::SafeError,
14 profile,
15 vault::Vault,
16};
17use zeroize::Zeroize;
18
19struct Session {
20 token: String,
21 profile: String,
22 password: String,
23 expires_at: chrono::DateTime<Utc>,
24}
25
26impl Drop for Session {
27 fn drop(&mut self) {
28 self.password.zeroize();
29 self.token.zeroize();
30 }
31}
32
33fn message_hostname(msg: &Value) -> Option<&str> {
34 msg["hostname"]
35 .as_str()
36 .map(str::trim)
37 .filter(|s| !s.is_empty())
38}
39
40fn validated_message_hostname(msg: &Value) -> Result<&str, Value> {
41 let hostname = message_hostname(msg)
42 .ok_or_else(|| json!({"status": "error", "message": "missing hostname"}))?;
43 if let Some(err) = browser_hostname_rpc_error(Some(hostname)) {
44 return Err(err);
45 }
46 Ok(hostname)
47}
48
49fn browser_request_allowed(session: &Session, hostname: &str) -> bool {
50 matches!(
51 profile::resolve_browser_profile(hostname),
52 Ok(Some(mapped_profile)) if mapped_profile == session.profile
53 )
54}
55
56fn browser_hostname_rpc_error(hostname: Option<&str>) -> Option<Value> {
58 let h = hostname?;
59 profile::browser_hostname_fill_guard(h)
60 .err()
61 .map(|reason| json!({"status": "error", "message": format!("hostname rejected: {reason}")}))
62}
63
64fn parse_json_body(body: &[u8]) -> anyhow::Result<Value> {
65 Ok(serde_json::from_slice(body)?)
66}
67
68pub(crate) fn read_message_from<R: Read>(mut r: R) -> anyhow::Result<Value> {
70 let mut len_buf = [0u8; 4];
71 r.read_exact(&mut len_buf)?;
72 let len = u32::from_le_bytes(len_buf) as usize;
73 if len > 1_048_576 {
74 anyhow::bail!("message too large: {len}");
75 }
76 let mut body = vec![0u8; len];
77 r.read_exact(&mut body)?;
78 parse_json_body(&body)
79}
80
81fn read_message() -> anyhow::Result<Value> {
82 read_message_from(io::stdin().lock())
83}
84
85fn write_message(resp: &Value) -> anyhow::Result<()> {
86 let bytes = serde_json::to_vec(resp)?;
87 let len = bytes.len() as u32;
88 io::stdout().write_all(&len.to_le_bytes())?;
89 io::stdout().write_all(&bytes)?;
90 io::stdout().flush()?;
91 Ok(())
92}
93
94fn check_session(session: &mut Option<Session>, token: &str) -> bool {
97 if let Some(ref mut s) = session {
98 if s.token == token {
99 if Utc::now() < s.expires_at {
100 s.expires_at = Utc::now() + chrono::Duration::minutes(5);
101 return true;
102 } else {
103 *session = None;
104 }
105 }
106 }
107 false
108}
109
110fn append_browser_audit(profile_name: &str, operation: &str, key: Option<&str>) {
111 AuditLog::new(&profile::audit_log_path(profile_name))
112 .append(&AuditEntry::success(profile_name, operation, key))
113 .ok();
114}
115
116pub(crate) fn dispatch(session: &mut Option<Session>, msg: Value) -> Value {
117 let command = msg["command"].as_str().unwrap_or("");
118 match command {
119 "unlock" => {
120 let profile_name = msg["profile"].as_str().unwrap_or("default").to_string();
121 if profile::validate_profile_name(&profile_name).is_err() {
122 return json!({"status": "error", "message": "invalid profile name"});
123 }
124 let password = match msg["password"].as_str() {
125 Some(p) => p.to_string(),
126 None => return json!({"status": "error", "message": "missing password"}),
127 };
128 let vault_path = profile::vault_path(&profile_name);
129 match Vault::open(&vault_path, password.as_bytes()) {
130 Ok(_) => {
131 let token = uuid::Uuid::new_v4().to_string();
132 let expires_at = Utc::now() + chrono::Duration::minutes(5);
133 *session = Some(Session {
134 token: token.clone(),
135 profile: profile_name.clone(),
136 password,
137 expires_at,
138 });
139 json!({
140 "status": "ok",
141 "session_token": token,
142 "profile": profile_name,
143 "expires_at": expires_at.to_rfc3339(),
144 })
145 }
146 Err(SafeError::DecryptionFailed) => {
147 json!({"status": "error", "message": "wrong password"})
148 }
149 Err(SafeError::VaultNotFound { .. }) => {
150 json!({"status": "error", "message": format!("no vault for profile '{profile_name}'")})
151 }
152 Err(e) => json!({"status": "error", "message": e.to_string()}),
153 }
154 }
155
156 "lock" => {
157 *session = None;
158 json!({"status": "ok"})
159 }
160
161 "list_logins" => {
162 let token = msg["session_token"].as_str().unwrap_or("");
163 if !check_session(session, token) {
164 return json!({"status": "error", "message": "session expired"});
165 }
166 let sess = session.as_ref().unwrap();
167 let hostname = match validated_message_hostname(&msg) {
168 Ok(hostname) => hostname,
169 Err(err) => return err,
170 };
171 if !browser_request_allowed(sess, hostname) {
172 let profiles = profile::load_browser_profiles().unwrap_or_default();
175 if let Some(m) = profile::lookalike_check(hostname, &profiles) {
176 return json!({
177 "status": "phishing_warning",
178 "registered": m.registered,
179 });
180 }
181 return json!({"status": "error", "message": "domain is not mapped to the unlocked profile"});
182 }
183 let vault_path = profile::vault_path(&sess.profile);
184 match Vault::open(&vault_path, sess.password.as_bytes()) {
185 Ok(vault) => {
186 let mut logins: Vec<Value> = vault
187 .file()
188 .secrets
189 .iter()
190 .filter(|(_, e)| {
191 e.tags.get("type").map(|t| t != "alias").unwrap_or(true)
193 })
194 .map(|(k, e)| {
195 let pinned = e.tags.get("pinned").map(|v| v == "true").unwrap_or(false);
196 json!({"key": k, "pinned": pinned})
197 })
198 .collect();
199 logins.sort_by(|a, b| {
201 let ap = a["pinned"].as_bool().unwrap_or(false);
202 let bp = b["pinned"].as_bool().unwrap_or(false);
203 bp.cmp(&ap).then_with(|| {
204 a["key"]
205 .as_str()
206 .unwrap_or("")
207 .cmp(b["key"].as_str().unwrap_or(""))
208 })
209 });
210 append_browser_audit(&sess.profile, "browser-list", None);
211 json!({"status": "ok", "logins": logins})
212 }
213 Err(e) => json!({"status": "error", "message": e.to_string()}),
214 }
215 }
216
217 "get_login" => {
218 let token = msg["session_token"].as_str().unwrap_or("");
219 if !check_session(session, token) {
220 return json!({"status": "error", "message": "session expired"});
221 }
222 let key = match msg["key"].as_str() {
223 Some(k) => k.to_string(),
224 None => return json!({"status": "error", "message": "missing key"}),
225 };
226 let sess = session.as_ref().unwrap();
227 let hostname = match validated_message_hostname(&msg) {
228 Ok(hostname) => hostname,
229 Err(err) => return err,
230 };
231 if !browser_request_allowed(sess, hostname) {
232 return json!({"status": "error", "message": "domain is not mapped to the unlocked profile"});
233 }
234 let vault_path = profile::vault_path(&sess.profile);
235 match Vault::open(&vault_path, sess.password.as_bytes()) {
236 Ok(vault) => match vault.get(&key) {
237 Ok(value) => {
238 append_browser_audit(&sess.profile, "browser-get", Some(&key));
239 json!({"status": "ok", "value": value.as_ref() as &str})
240 }
241 Err(_) => json!({"status": "error", "message": "key not found"}),
242 },
243 Err(e) => json!({"status": "error", "message": e.to_string()}),
244 }
245 }
246
247 "save_login" => {
248 let token = msg["session_token"].as_str().unwrap_or("");
249 if !check_session(session, token) {
250 return json!({"status": "error", "message": "session expired"});
251 }
252 let key = match msg["key"].as_str() {
253 Some(k) => k.to_string(),
254 None => return json!({"status": "error", "message": "missing key"}),
255 };
256 let value = match msg["value"].as_str() {
257 Some(v) => v.to_string(),
258 None => return json!({"status": "error", "message": "missing value"}),
259 };
260 let sess = session.as_ref().unwrap();
261 let hostname = match validated_message_hostname(&msg) {
262 Ok(hostname) => hostname,
263 Err(err) => return err,
264 };
265 if !browser_request_allowed(sess, hostname) {
266 return json!({"status": "error", "message": "domain is not mapped to the unlocked profile"});
267 }
268 let vault_path = profile::vault_path(&sess.profile);
269 match Vault::open(&vault_path, sess.password.as_bytes()) {
270 Ok(mut vault) => match vault.set(&key, &value, HashMap::new()) {
271 Ok(()) => {
272 append_browser_audit(&sess.profile, "browser-save", Some(&key));
273 json!({"status": "ok"})
274 }
275 Err(e) => json!({"status": "error", "message": e.to_string()}),
276 },
277 Err(e) => json!({"status": "error", "message": e.to_string()}),
278 }
279 }
280
281 other => json!({"status": "error", "message": format!("unknown command: {other}")}),
282 }
283}
284
285fn message_loop() {
286 let mut session: Option<Session> = None;
287 loop {
288 match read_message() {
289 Ok(msg) => {
290 let id = msg.get("id").cloned();
295 let mut resp = dispatch(&mut session, msg);
296 if let Some(id) = id {
297 if let Some(obj) = resp.as_object_mut() {
298 obj.insert("id".to_string(), id);
299 }
300 }
301 if let Err(e) = write_message(&resp) {
302 eprintln!("nativehost: write error: {e}");
303 break;
304 }
305 }
306 Err(e) => {
307 let s = e.to_string();
309 if s.contains("failed to fill whole buffer")
310 || s.contains("unexpected end")
311 || s.contains("os error")
312 {
313 break;
314 }
315 eprintln!("nativehost: read error: {e}");
316 break;
317 }
318 }
319 }
320}
321
322pub fn run() {
327 #[cfg(target_os = "windows")]
330 {
331 unsafe extern "C" {
332 fn _setmode(fd: std::ffi::c_int, mode: std::ffi::c_int) -> std::ffi::c_int;
333 }
334
335 unsafe {
336 _setmode(0, 0x8000);
337 _setmode(1, 0x8000);
338 }
339 }
340
341 message_loop();
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use std::io::Cursor;
348
349 use tsafe_core::{profile, vault::Vault};
350
351 fn framed_json(bytes: &[u8]) -> Vec<u8> {
352 let mut v = Vec::with_capacity(4 + bytes.len());
353 v.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
354 v.extend_from_slice(bytes);
355 v
356 }
357
358 #[test]
359 fn read_message_from_accepts_valid_length_prefixed_json() {
360 let payload = br#"{"command":"lock"}"#;
361 let v = read_message_from(Cursor::new(framed_json(payload))).unwrap();
362 assert_eq!(v["command"], "lock");
363 }
364
365 #[test]
366 fn read_message_from_rejects_oversized_length_without_reading_body() {
367 let len = (1_048_576 + 1) as u32;
368 let err = read_message_from(Cursor::new(len.to_le_bytes().to_vec())).unwrap_err();
369 assert!(
370 err.to_string().contains("message too large"),
371 "unexpected: {err}"
372 );
373 }
374
375 #[test]
376 fn read_message_from_errors_on_truncated_body() {
377 let mut buf = framed_json(br"{}");
378 buf.truncate(4 + 1); let err = read_message_from(Cursor::new(buf)).unwrap_err();
380 let s = err.to_string().to_lowercase();
381 assert!(
382 s.contains("failed to fill") || s.contains("unexpected end"),
383 "unexpected: {err}"
384 );
385 }
386
387 #[test]
388 fn read_message_from_errors_on_eof_before_length() {
389 let err = read_message_from(Cursor::new([])).unwrap_err();
390 let s = err.to_string().to_lowercase();
391 assert!(
392 s.contains("failed to fill") || s.contains("unexpected end"),
393 "unexpected: {err}"
394 );
395 }
396
397 #[test]
398 fn read_message_from_empty_body_fails_json_parse() {
399 let mut v = vec![0u8; 4];
400 v.copy_from_slice(&0u32.to_le_bytes());
401 let err = read_message_from(Cursor::new(v)).unwrap_err();
402 let s = err.to_string().to_lowercase();
403 assert!(
404 s.contains("eof") || s.contains("expected") || s.contains("parse"),
405 "unexpected: {err}"
406 );
407 }
408
409 #[test]
410 fn parse_json_body_rejects_malformed_json() {
411 let err = parse_json_body(br"{not json").unwrap_err();
412 let msg = err.to_string();
413 assert!(
414 msg.contains("key must be a string") || msg.contains("EOF") || msg.contains("expected"),
415 "unexpected error: {msg}"
416 );
417 }
418
419 #[test]
420 fn parse_json_body_rejects_truncated_payload() {
421 let err = parse_json_body(r#"{"command":"#.as_bytes()).unwrap_err();
422 let lower = err.to_string().to_lowercase();
423 assert!(
424 lower.contains("eof") || lower.contains("unexpected"),
425 "unexpected error: {err}"
426 );
427 }
428
429 #[test]
430 fn parse_json_body_accepts_minimal_object() {
431 let v = parse_json_body(br#"{"command":"lock"}"#).unwrap();
432 assert_eq!(v["command"], "lock");
433 }
434
435 #[test]
436 fn dispatch_unknown_command_returns_error() {
437 let mut session = None;
438 let r = dispatch(&mut session, json!({"command": "not-a-real-command"}));
439 assert_eq!(r["status"], "error");
440 assert!(r["message"]
441 .as_str()
442 .unwrap_or("")
443 .contains("unknown command"));
444 }
445
446 #[test]
447 fn dispatch_unlock_rejects_invalid_profile_name() {
448 let mut session = None;
449 let r = dispatch(
450 &mut session,
451 json!({"command": "unlock", "profile": "evil/../../x", "password": "x"}),
452 );
453 assert_eq!(r["status"], "error");
454 assert_eq!(r["message"], "invalid profile name");
455 assert!(session.is_none());
456 }
457
458 #[test]
459 fn dispatch_unlock_requires_password_field() {
460 let mut session = None;
461 let r = dispatch(
462 &mut session,
463 json!({"command": "unlock", "profile": "default"}),
464 );
465 assert_eq!(r["status"], "error");
466 assert_eq!(r["message"], "missing password");
467 }
468
469 #[test]
470 fn dispatch_list_logins_without_session_returns_expired() {
471 let mut session = None;
472 let r = dispatch(
473 &mut session,
474 json!({"command": "list_logins", "session_token": "nope"}),
475 );
476 assert_eq!(r["status"], "error");
477 assert_eq!(r["message"], "session expired");
478 }
479
480 #[test]
481 fn message_hostname_trims_and_skips_empty() {
482 assert_eq!(message_hostname(&json!({"hostname": " "})), None);
483 assert_eq!(
484 message_hostname(&json!({"hostname": " example.com "})),
485 Some("example.com")
486 );
487 }
488
489 fn unlock_default_session(dir: &std::path::Path) -> (String, Option<Session>) {
490 temp_env::with_var("TSAFE_VAULT_DIR", Some(dir.to_str().unwrap()), || {
491 let vault_path = profile::vault_path("default");
492 std::fs::create_dir_all(vault_path.parent().unwrap()).unwrap();
493 let _ = std::fs::remove_file(&vault_path);
494 Vault::create(&vault_path, b"pw").unwrap();
495 let mut session = None;
496 let unlock = dispatch(
497 &mut session,
498 json!({"command": "unlock", "profile": "default", "password": "pw"}),
499 );
500 assert_eq!(unlock["status"], "ok", "{unlock:?}");
501 let token = unlock["session_token"].as_str().unwrap().to_string();
502 (token, session)
503 })
504 }
505
506 #[test]
507 fn dispatch_browser_paths_reject_malformed_hostname() {
508 let dir = tempfile::tempdir().unwrap();
509 let (token, mut session) = unlock_default_session(dir.path());
510 let bad_host = (0..14)
511 .map(|i| format!("l{i}"))
512 .collect::<Vec<_>>()
513 .join(".");
514
515 temp_env::with_var(
516 "TSAFE_VAULT_DIR",
517 Some(dir.path().to_str().unwrap()),
518 || {
519 let r = dispatch(
520 &mut session,
521 json!({
522 "command": "list_logins",
523 "session_token": token,
524 "hostname": bad_host,
525 }),
526 );
527 assert_eq!(r["status"], "error");
528 let msg = r["message"].as_str().unwrap();
529 assert!(
530 msg.contains("hostname rejected") && msg.contains("too many"),
531 "unexpected message: {msg}"
532 );
533
534 let r = dispatch(
535 &mut session,
536 json!({
537 "command": "get_login",
538 "session_token": token,
539 "hostname": bad_host,
540 "key": "any",
541 }),
542 );
543 assert_eq!(r["status"], "error");
544 assert!(r["message"].as_str().unwrap().contains("hostname rejected"));
545
546 let r = dispatch(
547 &mut session,
548 json!({
549 "command": "save_login",
550 "session_token": token,
551 "hostname": bad_host,
552 "key": "k",
553 "value": "v",
554 }),
555 );
556 assert_eq!(r["status"], "error");
557 assert!(r["message"].as_str().unwrap().contains("hostname rejected"));
558 },
559 );
560 }
561
562 #[test]
563 fn dispatch_list_logins_returns_phishing_warning_for_lookalike_hostname() {
564 let dir = tempfile::tempdir().unwrap();
565 let (token, mut session) = unlock_default_session(dir.path());
566
567 let profiles_path = dir.path().join("browser-profiles.json");
569 std::fs::write(&profiles_path, r#"{"paypal.com":"default"}"#).unwrap();
570
571 temp_env::with_var(
572 "TSAFE_VAULT_DIR",
573 Some(dir.path().to_str().unwrap()),
574 || {
575 let r = dispatch(
577 &mut session,
578 json!({
579 "command": "list_logins",
580 "session_token": token,
581 "hostname": "paypa1.com",
582 }),
583 );
584 assert_eq!(
585 r["status"].as_str().unwrap(),
586 "phishing_warning",
587 "expected phishing_warning, got: {r:?}"
588 );
589 assert_eq!(r["registered"].as_str().unwrap(), "paypal.com");
590 },
591 );
592 }
593
594 #[test]
595 fn dispatch_browser_paths_require_hostname() {
596 let dir = tempfile::tempdir().unwrap();
597 let (token, mut session) = unlock_default_session(dir.path());
598
599 temp_env::with_var(
600 "TSAFE_VAULT_DIR",
601 Some(dir.path().to_str().unwrap()),
602 || {
603 let r = dispatch(
604 &mut session,
605 json!({"command": "list_logins", "session_token": token}),
606 );
607 assert_eq!(r["status"], "error");
608 assert_eq!(r["message"], "missing hostname");
609
610 let r = dispatch(
611 &mut session,
612 json!({
613 "command": "get_login",
614 "session_token": token,
615 "key": "any",
616 }),
617 );
618 assert_eq!(r["status"], "error");
619 assert_eq!(r["message"], "missing hostname");
620
621 let r = dispatch(
622 &mut session,
623 json!({
624 "command": "save_login",
625 "session_token": token,
626 "key": "k",
627 "value": "v",
628 }),
629 );
630 assert_eq!(r["status"], "error");
631 assert_eq!(r["message"], "missing hostname");
632 },
633 );
634 }
635
636 #[test]
637 fn dispatch_preserves_session_across_messages_for_id_echo_and_continuity() {
638 let dir = tempfile::tempdir().unwrap();
649 let (token, mut session) = unlock_default_session(dir.path());
650
651 temp_env::with_var(
652 "TSAFE_VAULT_DIR",
653 Some(dir.path().to_str().unwrap()),
654 || {
655 let profiles_path = dir.path().join("browser-profiles.json");
656 std::fs::write(&profiles_path, r#"{"github.com":"default"}"#).unwrap();
657
658 let resp1 = dispatch(
662 &mut session,
663 json!({
664 "command": "list_logins",
665 "session_token": token,
666 "hostname": "github.com",
667 "id": 42,
668 }),
669 );
670 assert_eq!(resp1["status"], "ok");
671 assert!(
676 resp1.is_object(),
677 "dispatch must return a JSON object so the main loop can echo `id`"
678 );
679
680 let resp2 = dispatch(
682 &mut session,
683 json!({
684 "command": "list_logins",
685 "session_token": token,
686 "hostname": "github.com",
687 "id": 43,
688 }),
689 );
690 assert_eq!(
691 resp2["status"], "ok",
692 "session must survive dispatch-to-dispatch"
693 );
694 },
695 );
696 }
697
698 #[test]
699 fn browser_reads_append_audit_entries() {
700 let dir = tempfile::tempdir().unwrap();
701 let (token, mut session) = unlock_default_session(dir.path());
702
703 temp_env::with_var(
704 "TSAFE_VAULT_DIR",
705 Some(dir.path().to_str().unwrap()),
706 || {
707 let profiles_path = dir.path().join("browser-profiles.json");
708 std::fs::write(&profiles_path, r#"{"github.com":"default"}"#).unwrap();
709
710 let vault_path = profile::vault_path("default");
711 let mut vault = Vault::open(&vault_path, b"pw").unwrap();
712 vault
713 .set("github.com/octocat", "secret-value", HashMap::new())
714 .unwrap();
715 drop(vault);
716
717 let list_resp = dispatch(
718 &mut session,
719 json!({
720 "command": "list_logins",
721 "session_token": token,
722 "hostname": "github.com",
723 }),
724 );
725 assert_eq!(list_resp["status"], "ok");
726
727 let get_resp = dispatch(
728 &mut session,
729 json!({
730 "command": "get_login",
731 "session_token": token,
732 "hostname": "github.com",
733 "key": "github.com/octocat",
734 }),
735 );
736 assert_eq!(get_resp["status"], "ok");
737
738 let audit_path = profile::audit_log_path("default");
739 let content = std::fs::read_to_string(audit_path).unwrap();
740 assert!(content.contains("\"operation\":\"browser-list\""));
741 assert!(content.contains("\"operation\":\"browser-get\""));
742 assert!(content.contains("\"key\":\"github.com/octocat\""));
743 },
744 );
745 }
746}