Skip to main content

subc_transport/
connection_file.rs

1use std::{
2    error::Error,
3    fmt,
4    fs::{self, File, OpenOptions},
5    io::{self, Write},
6    path::{Path, PathBuf},
7    process,
8};
9
10use serde::{Deserialize, Serialize};
11use subc_protocol::PROTOCOL_VERSION;
12
13pub const SCHEMA_VERSION: u32 = 1;
14pub const MIN_KEY_LEN: usize = 32;
15pub const KEY_LEN: usize = 32;
16pub const DAEMON_ID_LEN: usize = 16;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Endpoint {
20    pub host: String,
21    pub port: u16,
22}
23
24#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ConnectionInfo {
26    pub schema: u32,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub wire_version: Option<u8>,
29    pub endpoints: Vec<Endpoint>,
30    pub key: Vec<u8>,
31    pub daemon_id: [u8; DAEMON_ID_LEN],
32    pub pid: u32,
33    pub daemon_ver: String,
34}
35
36// Hand-written so the transport key is never printed. A derived Debug would dump
37// the raw key bytes into any log or panic message that formats a ConnectionInfo.
38impl fmt::Debug for ConnectionInfo {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.debug_struct("ConnectionInfo")
41            .field("schema", &self.schema)
42            .field("wire_version", &self.wire_version)
43            .field("endpoints", &self.endpoints)
44            .field("key", &format_args!("<{} bytes redacted>", self.key.len()))
45            .field("daemon_id", &self.daemon_id)
46            .field("pid", &self.pid)
47            .field("daemon_ver", &self.daemon_ver)
48            .finish()
49    }
50}
51
52impl ConnectionInfo {
53    pub fn validate(&self) -> Result<(), ConnectionFileError> {
54        if self.schema != SCHEMA_VERSION {
55            return Err(ConnectionFileError::UnsupportedSchema {
56                schema: self.schema,
57                supported: SCHEMA_VERSION,
58            });
59        }
60        if self.endpoints.is_empty() {
61            return Err(ConnectionFileError::Invalid {
62                reason: "connection file must include at least one endpoint".to_owned(),
63            });
64        }
65        if self.key.len() < MIN_KEY_LEN {
66            return Err(ConnectionFileError::KeyTooShort {
67                len: self.key.len(),
68                min: MIN_KEY_LEN,
69            });
70        }
71        Ok(())
72    }
73
74    /// Validates a declared envelope version without rejecting older files that
75    /// omit the additive field.
76    pub fn validate_wire_version(&self, supported: u8) -> Result<(), ConnectionFileError> {
77        if let Some(file) = self.wire_version {
78            if file != supported {
79                return Err(ConnectionFileError::WireVersionMismatch { file, supported });
80            }
81        }
82        Ok(())
83    }
84}
85
86#[derive(Debug)]
87pub enum ConnectionFileError {
88    MissingParent {
89        path: PathBuf,
90    },
91    MissingFileName {
92        path: PathBuf,
93    },
94    Io {
95        op: &'static str,
96        path: PathBuf,
97        source: io::Error,
98    },
99    JsonRead {
100        path: PathBuf,
101        source: serde_json::Error,
102    },
103    JsonWrite {
104        path: PathBuf,
105        source: serde_json::Error,
106    },
107    Random(getrandom::Error),
108    UnsupportedSchema {
109        schema: u32,
110        supported: u32,
111    },
112    WireVersionMismatch {
113        file: u8,
114        supported: u8,
115    },
116    Invalid {
117        reason: String,
118    },
119    KeyTooShort {
120        len: usize,
121        min: usize,
122    },
123    InsecurePermissions {
124        path: PathBuf,
125        mode: u32,
126    },
127}
128
129pub fn write_atomic(
130    path: impl AsRef<Path>,
131    info: &ConnectionInfo,
132) -> Result<(), ConnectionFileError> {
133    let path = path.as_ref();
134    info.validate()?;
135
136    let parent = path
137        .parent()
138        .filter(|parent| !parent.as_os_str().is_empty())
139        .ok_or_else(|| ConnectionFileError::MissingParent {
140            path: path.to_path_buf(),
141        })?;
142    let file_name = path
143        .file_name()
144        .ok_or_else(|| ConnectionFileError::MissingFileName {
145            path: path.to_path_buf(),
146        })?;
147    let temp_path = temp_path(parent, file_name)?;
148    let result = write_atomic_inner(path, &temp_path, info);
149    if result.is_err() {
150        let _ = fs::remove_file(&temp_path);
151    }
152    result
153}
154
155pub fn read(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
156    let path = path.as_ref();
157    // Refuse to trust a key from a file other local users can read. The key is
158    // published owner-only (0600); if the on-disk file is group/world-accessible
159    // the secret has leaked and the daemon it points at can't be trusted.
160    verify_owner_only(path)?;
161    let bytes = fs::read(path).map_err(|source| ConnectionFileError::Io {
162        op: "read",
163        path: path.to_path_buf(),
164        source,
165    })?;
166    let info: ConnectionInfo =
167        serde_json::from_slice(&bytes).map_err(|source| ConnectionFileError::JsonRead {
168            path: path.to_path_buf(),
169            source,
170        })?;
171    info.validate()?;
172    Ok(info)
173}
174
175/// Reads connection information for a client and rejects a declared envelope
176/// version this binary cannot decode before a TCP connection is attempted.
177pub fn read_for_client(path: impl AsRef<Path>) -> Result<ConnectionInfo, ConnectionFileError> {
178    let info = read(path)?;
179    info.validate_wire_version(PROTOCOL_VERSION)?;
180    Ok(info)
181}
182
183#[cfg(unix)]
184fn verify_owner_only(path: &Path) -> Result<(), ConnectionFileError> {
185    use std::os::unix::fs::PermissionsExt;
186    let meta = fs::metadata(path).map_err(|source| ConnectionFileError::Io {
187        op: "stat",
188        path: path.to_path_buf(),
189        source,
190    })?;
191    let mode = meta.permissions().mode();
192    // Any group or other permission bit means the key is exposed beyond the owner.
193    // A file owned by a different user that we can still read implies the same.
194    if mode & 0o077 != 0 {
195        return Err(ConnectionFileError::InsecurePermissions {
196            path: path.to_path_buf(),
197            mode: mode & 0o777,
198        });
199    }
200    Ok(())
201}
202
203#[cfg(not(unix))]
204fn verify_owner_only(_path: &Path) -> Result<(), ConnectionFileError> {
205    // On Windows the file inherits the per-user profile directory's ACL (owner,
206    // SYSTEM, Administrators only) at create time; see open_owner_only_new. There
207    // are no portable Unix mode bits to re-check on read here.
208    Ok(())
209}
210
211pub fn generate_key() -> Result<Vec<u8>, ConnectionFileError> {
212    let mut key = vec![0u8; KEY_LEN];
213    getrandom::getrandom(&mut key).map_err(ConnectionFileError::Random)?;
214    Ok(key)
215}
216
217pub fn generate_daemon_id() -> Result<[u8; DAEMON_ID_LEN], ConnectionFileError> {
218    let mut daemon_id = [0u8; DAEMON_ID_LEN];
219    getrandom::getrandom(&mut daemon_id).map_err(ConnectionFileError::Random)?;
220    Ok(daemon_id)
221}
222
223fn write_atomic_inner(
224    path: &Path,
225    temp_path: &Path,
226    info: &ConnectionInfo,
227) -> Result<(), ConnectionFileError> {
228    let json =
229        serde_json::to_vec_pretty(info).map_err(|source| ConnectionFileError::JsonWrite {
230            path: path.to_path_buf(),
231            source,
232        })?;
233
234    {
235        let mut file =
236            open_owner_only_new(temp_path).map_err(|source| ConnectionFileError::Io {
237                op: "create_temp",
238                path: temp_path.to_path_buf(),
239                source,
240            })?;
241        file.write_all(&json)
242            .and_then(|()| file.sync_all())
243            .map_err(|source| ConnectionFileError::Io {
244                op: "write_temp",
245                path: temp_path.to_path_buf(),
246                source,
247            })?;
248    }
249
250    fs::rename(temp_path, path).map_err(|source| ConnectionFileError::Io {
251        op: "rename",
252        path: path.to_path_buf(),
253        source,
254    })?;
255    Ok(())
256}
257
258fn open_owner_only_new(path: &Path) -> io::Result<File> {
259    let mut options = OpenOptions::new();
260    options.write(true).create_new(true);
261    #[cfg(unix)]
262    {
263        use std::os::unix::fs::OpenOptionsExt;
264        options.mode(0o600);
265    }
266    #[cfg(windows)]
267    {
268        // No explicit DACL is set: the connection file is published under the
269        // per-user profile (XDG_RUNTIME_DIR is unset on Windows, so
270        // connection_file_path() falls back to %TEMP% =
271        // %LOCALAPPDATA%\Temp). That directory's inherited ACL already grants
272        // access to only the owning user, SYSTEM, and Administrators — so the
273        // same-host, non-admin attacker (the threat 0600 guards against on the
274        // world-readable Unix /tmp) cannot read the key here. Administrators can
275        // read any file (SeBackup/SeTakeOwnership) on either platform and are
276        // out of scope for a same-host secret. Revisit an explicit owner-only
277        // SECURITY_DESCRIPTOR only if the connection file ever moves off the
278        // per-user profile directory.
279    }
280    options.open(path)
281}
282
283fn temp_path(parent: &Path, file_name: &std::ffi::OsStr) -> Result<PathBuf, ConnectionFileError> {
284    let mut suffix = [0u8; 16];
285    getrandom::getrandom(&mut suffix).map_err(ConnectionFileError::Random)?;
286    let file_name = file_name.to_string_lossy();
287    Ok(parent.join(format!(
288        ".{file_name}.{}.{}.tmp",
289        process::id(),
290        hex(&suffix)
291    )))
292}
293
294fn hex(bytes: &[u8]) -> String {
295    const HEX: &[u8; 16] = b"0123456789abcdef";
296    let mut out = String::with_capacity(bytes.len() * 2);
297    for byte in bytes {
298        out.push(HEX[(byte >> 4) as usize] as char);
299        out.push(HEX[(byte & 0x0f) as usize] as char);
300    }
301    out
302}
303
304impl fmt::Display for ConnectionFileError {
305    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
306        match self {
307            Self::MissingParent { path } => {
308                write!(f, "connection file path has no parent: {}", path.display())
309            }
310            Self::MissingFileName { path } => {
311                write!(
312                    f,
313                    "connection file path has no file name: {}",
314                    path.display()
315                )
316            }
317            Self::Io { op, path, source } => write!(
318                f,
319                "connection file {op} failed for {}: {source}",
320                path.display()
321            ),
322            Self::JsonRead { path, source } => write!(
323                f,
324                "connection file JSON read failed for {}: {source}",
325                path.display()
326            ),
327            Self::JsonWrite { path, source } => write!(
328                f,
329                "connection file JSON write failed for {}: {source}",
330                path.display()
331            ),
332            Self::Random(source) => write!(f, "connection file random generation failed: {source}"),
333            Self::UnsupportedSchema { schema, supported } => write!(
334                f,
335                "unsupported connection file schema {schema}; expected {supported}"
336            ),
337            Self::WireVersionMismatch { file, supported } => write!(
338                f,
339                "connection file wire version {file} does not match supported wire version {supported}; the binary must be upgraded"
340            ),
341            Self::Invalid { reason } => write!(f, "invalid connection file: {reason}"),
342            Self::KeyTooShort { len, min } => write!(
343                f,
344                "connection file key is too short: {len} bytes, need at least {min}"
345            ),
346            Self::InsecurePermissions { path, mode } => write!(
347                f,
348                "connection file {} has insecure permissions {mode:#o}; expected owner-only 0600",
349                path.display()
350            ),
351        }
352    }
353}
354
355impl Error for ConnectionFileError {
356    fn source(&self) -> Option<&(dyn Error + 'static)> {
357        match self {
358            Self::Io { source, .. } => Some(source),
359            Self::JsonRead { source, .. } | Self::JsonWrite { source, .. } => Some(source),
360            Self::Random(_) => None,
361            Self::MissingParent { .. }
362            | Self::MissingFileName { .. }
363            | Self::UnsupportedSchema { .. }
364            | Self::WireVersionMismatch { .. }
365            | Self::Invalid { .. }
366            | Self::KeyTooShort { .. }
367            | Self::InsecurePermissions { .. } => None,
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    fn sample_info() -> ConnectionInfo {
377        ConnectionInfo {
378            schema: SCHEMA_VERSION,
379            wire_version: None,
380            endpoints: vec![Endpoint {
381                host: "127.0.0.1".to_owned(),
382                port: 8799,
383            }],
384            key: vec![0xABu8; KEY_LEN],
385            daemon_id: [0x11u8; DAEMON_ID_LEN],
386            pid: 4242,
387            daemon_ver: "subc-test".to_owned(),
388        }
389    }
390
391    fn unique_temp_path() -> PathBuf {
392        let mut suffix = [0u8; 8];
393        getrandom::getrandom(&mut suffix).expect("random suffix");
394        let mut name = String::from("subc-connfile-test-");
395        for byte in suffix {
396            name.push_str(&format!("{byte:02x}"));
397        }
398        name.push_str(".json");
399        std::env::temp_dir().join(name)
400    }
401
402    #[test]
403    fn debug_redacts_key_bytes() {
404        let info = sample_info();
405        let rendered = format!("{info:?}");
406        assert!(
407            rendered.contains("redacted"),
408            "Debug must mark the key as redacted: {rendered}"
409        );
410        // The raw key byte pattern (0xab) must not appear anywhere in the output.
411        assert!(
412            !rendered.contains("171") && !rendered.to_lowercase().contains("ab, ab"),
413            "Debug must not leak raw key bytes: {rendered}"
414        );
415    }
416
417    #[test]
418    fn validate_rejects_unsupported_schema_empty_endpoints_and_short_key() {
419        let mut unsupported_schema = sample_info();
420        unsupported_schema.schema = SCHEMA_VERSION + 1;
421        let before = unsupported_schema.clone();
422        let err = unsupported_schema
423            .validate()
424            .expect_err("unsupported schema must be rejected");
425        assert!(matches!(
426            err,
427            ConnectionFileError::UnsupportedSchema {
428                schema,
429                supported: SCHEMA_VERSION,
430            } if schema == SCHEMA_VERSION + 1
431        ));
432        assert_eq!(unsupported_schema, before, "validate must not mutate input");
433
434        let mut empty_endpoints = sample_info();
435        empty_endpoints.endpoints.clear();
436        let before = empty_endpoints.clone();
437        let err = empty_endpoints
438            .validate()
439            .expect_err("empty endpoint list must be rejected");
440        assert!(matches!(
441            err,
442            ConnectionFileError::Invalid { ref reason }
443                if reason == "connection file must include at least one endpoint"
444        ));
445        assert_eq!(empty_endpoints, before, "validate must not mutate input");
446
447        let mut short_key = sample_info();
448        short_key.key = vec![0xAB; MIN_KEY_LEN - 1];
449        let before = short_key.clone();
450        let err = short_key
451            .validate()
452            .expect_err("short key must be rejected");
453        assert!(matches!(
454            err,
455            ConnectionFileError::KeyTooShort {
456                len,
457                min: MIN_KEY_LEN,
458            } if len == MIN_KEY_LEN - 1
459        ));
460        assert_eq!(short_key, before, "validate must not mutate input");
461    }
462
463    #[test]
464    fn optional_wire_version_round_trips() {
465        let path = unique_temp_path();
466        let legacy = sample_info();
467        write_atomic(&path, &legacy).expect("write legacy connection file");
468        let legacy_json = fs::read_to_string(&path).expect("read legacy connection file");
469        assert!(!legacy_json.contains("wire_version"));
470        assert_eq!(
471            read_for_client(&path).expect("legacy file remains readable"),
472            legacy
473        );
474
475        let mut current = sample_info();
476        current.wire_version = Some(PROTOCOL_VERSION);
477        write_atomic(&path, &current).expect("write current connection file");
478        let current_json = fs::read_to_string(&path).expect("read current connection file");
479        let current_json: serde_json::Value =
480            serde_json::from_str(&current_json).expect("parse current connection file");
481        assert_eq!(
482            current_json["wire_version"].as_u64(),
483            Some(u64::from(PROTOCOL_VERSION))
484        );
485        assert_eq!(
486            read_for_client(&path).expect("current file is readable"),
487            current
488        );
489        let _ = fs::remove_file(&path);
490    }
491
492    #[test]
493    fn read_for_client_rejects_mismatched_wire_version() {
494        let path = unique_temp_path();
495        let mut info = sample_info();
496        let file_version = PROTOCOL_VERSION + 1;
497        info.wire_version = Some(file_version);
498        write_atomic(&path, &info).expect("write mismatched connection file");
499
500        let err = read_for_client(&path).expect_err("mismatched wire version must fail discovery");
501        assert!(matches!(
502            err,
503            ConnectionFileError::WireVersionMismatch { file, supported }
504                if file == file_version && supported == PROTOCOL_VERSION
505        ));
506        let rendered = err.to_string();
507        assert!(rendered.contains(&file_version.to_string()));
508        assert!(rendered.contains(&PROTOCOL_VERSION.to_string()));
509        assert!(rendered.contains("binary must be upgraded"));
510        let _ = fs::remove_file(&path);
511    }
512
513    #[cfg(unix)]
514    #[test]
515    fn read_rejects_group_or_world_readable_file() {
516        use std::os::unix::fs::PermissionsExt;
517
518        let path = unique_temp_path();
519        write_atomic(&path, &sample_info()).expect("write owner-only file");
520        // Loosen permissions as if the key leaked to other local users.
521        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("relax permissions");
522
523        let err = read(&path).expect_err("group/world-readable key file must be rejected");
524        assert!(
525            matches!(err, ConnectionFileError::InsecurePermissions { mode, .. } if mode == 0o644),
526            "expected InsecurePermissions, got {err:?}"
527        );
528        let _ = fs::remove_file(&path);
529    }
530}