Skip to main content

gglib_core/access/
device_keys.rs

1//! The keys this machine issued to paired devices, on disk.
2//!
3//! Deliberately *not* in `settings_kv` beside `proxy_api_key`. That store is
4//! printed in full by `gglib config settings show` — unmasked on purpose, so a
5//! rotated key can be recovered — and that output is what people paste into
6//! bug reports. One shared key there is a known cost; a device key each would
7//! quietly undo what per-device revocation is for. The roster's readable half
8//! (ids, labels, last-seen) stays in settings; only the secrets are here.
9//!
10//! Same directory and same posture as the endpoint identity: `0600`, under
11//! `data/`, which a debug build resolves to the repository checkout where
12//! `.gitignore` covers it.
13
14use std::collections::BTreeMap;
15use std::fs;
16use std::io::{self, Write};
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicU64, Ordering};
19
20use crate::paths::{PathError, create_private_dir, remote_identity_path};
21
22/// Every device key this machine holds, by the id the tunnel edge knows it as.
23pub type DeviceKeys = BTreeMap<String, String>;
24
25/// Where the keys live: beside the endpoint identity.
26///
27/// # Errors
28///
29/// Whatever resolving the data root returns.
30pub fn device_keys_path() -> Result<PathBuf, PathError> {
31    Ok(remote_identity_path()?.with_file_name("remote_devices"))
32}
33
34/// Read the roster's keys, or an empty map when nothing has been issued.
35///
36/// A missing file is the empty map rather than an error: a machine that has
37/// never invited anything is not a machine in a bad state.
38///
39/// # Errors
40///
41/// [`io::Error`] when the file exists and cannot be read or parsed. A parse
42/// failure is *not* softened into an empty map — that would arm a listener
43/// admitting nobody while the roster in settings says otherwise, and the
44/// operator would see devices listed and refused at the same time.
45pub fn load(path: &Path) -> io::Result<DeviceKeys> {
46    match fs::read(path) {
47        Ok(bytes) => serde_json::from_slice(&bytes).map_err(io::Error::other),
48        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(DeviceKeys::new()),
49        Err(e) => Err(e),
50    }
51}
52
53/// Replace the stored keys, `0600`, atomically.
54///
55/// Written to a sibling temporary file and renamed, so a crash mid-write
56/// leaves the previous roster rather than a truncated one: a half-written file
57/// is a listener that admits some devices and not others, with nothing saying
58/// which.
59///
60/// The temporary file is `0600` from the moment it exists, not from a chmod
61/// once the keys are already in it; `create_private` says why. A temporary a
62/// crash leaves behind is therefore no more readable than the file it would
63/// have replaced, and it is not swept here: another process may be mid-write
64/// on a temporary of its own, and deleting that one brings back the rename
65/// collision the per-writer names below exist to prevent. The one exception
66/// is a leftover under this writer's own name, which `create_private` removes.
67///
68/// **The temporary file is named per writer, not per path.** A fixed
69/// `.tmp` sibling makes two concurrent writers collide on one filename:
70/// both write it, the first renames it away, and the second fails at its own
71/// `rename` with `NotFound` — an error raised for a write that was perfectly
72/// valid. The rename is what makes this atomic, and it only does so if each
73/// writer has its own thing to rename.
74///
75/// # Errors
76///
77/// [`io::Error`] from creating the directory, creating or writing the
78/// temporary file, removing a leftover under its name, setting its mode, or
79/// the rename.
80pub fn store(path: &Path, keys: &DeviceKeys) -> io::Result<()> {
81    if let Some(parent) = path.parent() {
82        create_private_dir(parent)?;
83    }
84    let json = serde_json::to_vec_pretty(keys).map_err(io::Error::other)?;
85
86    let tmp = path.with_extension(format!(
87        "tmp.{}.{}",
88        std::process::id(),
89        NEXT_TMP.fetch_add(1, Ordering::Relaxed)
90    ));
91    let written = create_private(&tmp)
92        .and_then(|mut file| {
93            restrict(&file)?;
94            file.write_all(&json)
95        })
96        .and_then(|()| fs::rename(&tmp, path));
97    if written.is_err() {
98        // Best effort: a temporary nobody renamed is litter beside a `0600`
99        // secret, and the error being returned is the one that matters.
100        let _ = fs::remove_file(&tmp);
101    }
102    written
103}
104
105/// Distinguishes one writer's temporary file from another's within a process;
106/// the pid does it across processes in one pid namespace.
107static NEXT_TMP: AtomicU64 = AtomicU64::new(0);
108
109/// Open the temporary file for writing: new, and `0600` from the moment it
110/// exists.
111///
112/// `fs::write` creates with `0666` less the umask, which is `0644` on most
113/// machines, and a mode set afterwards leaves a window in which every device
114/// key is on disk and readable by anyone on the machine. A crash inside that
115/// window leaves them that way for good, under a name nothing goes back to.
116/// Asking `open` for the mode closes the window; it is how modelpipe creates
117/// the endpoint identity beside this file.
118///
119/// **New, never reused.** A file already under this name — a pid used again,
120/// after a reboot or when pids wrap, starts the counter again — keeps the
121/// mode it has, and somebody may have opened it while that let them, holding
122/// a descriptor no chmod reaches; a symlink there sends the open, and its
123/// truncate, to whatever file it names. So `open_new` refuses a name that is
124/// taken, a link included, and the leftover is removed and the create tried
125/// once more: no other writer on this machine, in this pid namespace, can be
126/// using a name that carries this process's pid and a count only it drew.
127/// Removing a name writes nothing to the file it named, and removes a link
128/// rather than its target. A second refusal is returned rather than chased,
129/// and so is a leftover that cannot be removed, such as a directory.
130fn create_private(path: &Path) -> io::Result<fs::File> {
131    match open_new(path) {
132        Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
133            fs::remove_file(path)?;
134            open_new(path)
135        }
136        opened => opened,
137    }
138}
139
140/// `create_new`, with the mode asked of `open` itself.
141#[cfg(unix)]
142fn open_new(path: &Path) -> io::Result<fs::File> {
143    use std::os::unix::fs::OpenOptionsExt;
144    fs::OpenOptions::new()
145        .write(true)
146        .create_new(true)
147        .mode(0o600)
148        .open(path)
149}
150
151/// Windows has no mode to ask for, so this is `create_new` alone, which
152/// refuses a taken name as the Unix twin does; `restrict` says what protects
153/// the file there.
154#[cfg(not(unix))]
155fn open_new(path: &Path) -> io::Result<fs::File> {
156    fs::OpenOptions::new()
157        .write(true)
158        .create_new(true)
159        .open(path)
160}
161
162/// `0600` exactly where the platform has a notion of it: the umask can take
163/// bits from the mode `open_new` asked for, the owner's own among them. Set
164/// on the descriptor, so what changes is the file this writer created and
165/// not whatever is under its name by now.
166#[cfg(unix)]
167fn restrict(file: &fs::File) -> io::Result<()> {
168    use std::os::unix::fs::PermissionsExt;
169    file.set_permissions(fs::Permissions::from_mode(0o600))
170}
171
172/// Windows has no mode to set; the file inherits the directory's ACL, which is
173/// the same protection the endpoint identity gets there.
174#[cfg(not(unix))]
175#[allow(clippy::unnecessary_wraps, clippy::missing_const_for_fn)] // the Unix twin really can fail
176fn restrict(_file: &fs::File) -> io::Result<()> {
177    Ok(())
178}
179
180#[cfg(test)]
181#[path = "device_keys_tests.rs"]
182mod device_keys_tests;