anvilsign in

collin/browser-terminal-extension

1//! On-disk state: the auth token and the explicitly-paired origin list.
2//!
3//! Both live in a 0700 config dir. The token file is 0600 and we *refuse to use
4//! it* if the mode ever loosens — on a multi-user box, 127.0.0.1 is reachable by
5//! every local uid, so the file mode is the only thing keeping other users out.
6
7use std::fs;
8use std::io::{self, Write};
9use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
10use std::path::{Path, PathBuf};
11
12pub const TOKEN_BYTES: usize = 32;
13
14#[derive(Debug)]
15pub enum TokenError {
16 Io(io::Error),
17 /// The token file is readable by group or other. Refuse rather than
18 /// silently authenticate against a world-readable secret.
19 TooPermissive { path: PathBuf, mode: u32 },
20 Malformed,
21}
22
23impl std::fmt::Display for TokenError {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 match self {
26 TokenError::Io(e) => write!(f, "{e}"),
27 TokenError::TooPermissive { path, mode } => write!(
28 f,
29 "token file {} has mode {:04o}; expected 0600. \
30 Fix with: chmod 600 {}",
31 path.display(),
32 mode,
33 path.display()
34 ),
35 TokenError::Malformed => write!(f, "token file is empty or malformed"),
36 }
37 }
38}
39
40impl std::error::Error for TokenError {}
41
42impl From<io::Error> for TokenError {
43 fn from(e: io::Error) -> Self {
44 TokenError::Io(e)
45 }
46}
47
48pub fn config_dir() -> PathBuf {
49 if let Some(x) = std::env::var_os("TERMBRIDGE_CONFIG_DIR") {
50 return PathBuf::from(x);
51 }
52 let base = std::env::var_os("XDG_CONFIG_HOME")
53 .map(PathBuf::from)
54 .unwrap_or_else(|| {
55 let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
56 home.join(".config")
57 });
58 base.join("termbridge")
59}
60
61pub fn token_path() -> PathBuf {
62 config_dir().join("token")
63}
64
65pub fn paired_origins_path() -> PathBuf {
66 config_dir().join("paired-origins")
67}
68
69fn ensure_config_dir(dir: &Path) -> io::Result<()> {
70 fs::create_dir_all(dir)?;
71 // 0700: the dir itself should not be traversable by other users.
72 fs::set_permissions(dir, fs::Permissions::from_mode(0o700))
73}
74
75/// Generate a fresh CSPRNG token and write it 0600, replacing any existing one.
76pub fn generate_token(dir: &Path) -> Result<String, TokenError> {
77 ensure_config_dir(dir)?;
78 let token = random_hex(TOKEN_BYTES);
79 let path = dir.join("token");
80
81 // Create with 0600 *at open time* — never create-then-chmod, which leaves a
82 // window where the secret is world-readable.
83 let mut f = fs::OpenOptions::new()
84 .write(true)
85 .create(true)
86 .truncate(true)
87 .mode(0o600)
88 .open(&path)?;
89 f.write_all(token.as_bytes())?;
90 f.write_all(b"\n")?;
91 f.sync_all()?;
92
93 // An existing file keeps its old mode through O_CREAT, so enforce it too.
94 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
95 Ok(token)
96}
97
98/// Load the token, refusing if the file is group/other accessible.
99pub fn load_token(dir: &Path) -> Result<String, TokenError> {
100 let path = dir.join("token");
101 let meta = fs::metadata(&path)?;
102 let mode = meta.permissions().mode() & 0o777;
103 if mode & 0o077 != 0 {
104 return Err(TokenError::TooPermissive { path, mode });
105 }
106 let token = fs::read_to_string(&path)?.trim().to_string();
107 if token.is_empty() {
108 return Err(TokenError::Malformed);
109 }
110 Ok(token)
111}
112
113pub fn load_or_create_token(dir: &Path) -> Result<String, TokenError> {
114 match load_token(dir) {
115 Ok(t) => Ok(t),
116 Err(TokenError::Io(e)) if e.kind() == io::ErrorKind::NotFound => generate_token(dir),
117 Err(e) => Err(e),
118 }
119}
120
121/// Origins the user has *explicitly* approved. Absence of this file means no
122/// client can connect — pairing is never implicit.
123pub fn load_paired_origins(dir: &Path) -> Vec<String> {
124 let path = dir.join("paired-origins");
125 let Ok(contents) = fs::read_to_string(path) else {
126 return Vec::new();
127 };
128 contents
129 .lines()
130 .map(str::trim)
131 .filter(|l| !l.is_empty() && !l.starts_with('#'))
132 .map(|l| l.to_ascii_lowercase())
133 .collect()
134}
135
136pub fn pair_origin(dir: &Path, origin: &str) -> io::Result<bool> {
137 ensure_config_dir(dir)?;
138 let origin = origin.trim().to_ascii_lowercase();
139 let mut existing = load_paired_origins(dir);
140 if existing.iter().any(|o| o == &origin) {
141 return Ok(false);
142 }
143 existing.push(origin);
144 let path = dir.join("paired-origins");
145 let mut f = fs::OpenOptions::new()
146 .write(true)
147 .create(true)
148 .truncate(true)
149 .mode(0o600)
150 .open(&path)?;
151 writeln!(f, "# Origins approved to connect to termbridge. One per line.")?;
152 for o in &existing {
153 writeln!(f, "{o}")?;
154 }
155 Ok(true)
156}
157
158pub fn unpair_origin(dir: &Path, origin: &str) -> io::Result<bool> {
159 let origin = origin.trim().to_ascii_lowercase();
160 let existing = load_paired_origins(dir);
161 if !existing.iter().any(|o| o == &origin) {
162 return Ok(false);
163 }
164 let path = dir.join("paired-origins");
165 let mut f = fs::OpenOptions::new()
166 .write(true)
167 .create(true)
168 .truncate(true)
169 .mode(0o600)
170 .open(&path)?;
171 writeln!(f, "# Origins approved to connect to termbridge. One per line.")?;
172 for o in existing.iter().filter(|o| *o != &origin) {
173 writeln!(f, "{o}")?;
174 }
175 Ok(true)
176}
177
178fn random_hex(n: usize) -> String {
179 let mut buf = vec![0u8; n];
180 // Straight from the OS CSPRNG. Deliberately not a userspace PRNG — this is
181 // the only thing standing between another local uid and a shell.
182 getrandom::fill(&mut buf).expect("OS CSPRNG unavailable");
183 let mut s = String::with_capacity(n * 2);
184 for b in buf {
185 use std::fmt::Write as _;
186 let _ = write!(s, "{b:02x}");
187 }
188 s
189}