Skip to main content

macula_rust/node_key/
key_file.rs

1//! Key files in the seed form (`seed_form`), readable by their owner only:
2//! written owner-only (0o600 in a 0o700 directory) and refused on load unless
3//! the effective user owns the file and its group and others cannot read it.
4//! Unix only. On Windows a node key is kept in Credential Manager instead, and
5//! these calls refuse with [`KeyFileError::NoKeyFile`] (`no_key_file`,
6//! macula-rust#19).
7
8use std::io::{Read, Write};
9use std::path::Path;
10
11use super::seed_form::{parse, round_trip};
12use super::{KeyFileError, NodeKey, Purpose};
13use crate::profile::Profile;
14
15/// The most a load reads: a key file is a few KiB.
16const MAX_KEY_FILE_BYTES: u64 = 64 * 1024;
17impl NodeKey {
18    /// Writes the key to `path` in the seed form, readable by its owner only.
19    /// The file is created in a new owner-only directory beside `path`,
20    /// written, synced and renamed over any file at `path`; then `path`'s
21    /// directory is synced and the new one removed. Nothing else in the
22    /// directory is read, written or removed.
23    pub fn save(&self, path: &Path) -> Result<(), KeyFileError> {
24        self.place(path, Place::Replace)
25    }
26
27    /// Writes the key to `path` as [`NodeKey::save`] does, but only where
28    /// nothing is: the staged file is linked into place, which refuses an
29    /// existing file with `AlreadyExists` instead of replacing it.
30    fn save_new(&self, path: &Path) -> Result<(), KeyFileError> {
31        self.place(path, Place::CreateNew)
32    }
33
34    fn place(&self, path: &Path, place: Place) -> Result<(), KeyFileError> {
35        let dir = match path.parent() {
36            Some(d) if !d.as_os_str().is_empty() => d,
37            _ => Path::new("."),
38        };
39        create_dir_owner_only(dir, true)?;
40        let base = path
41            .file_name()
42            .ok_or(KeyFileError::NotRegular)?
43            .to_string_lossy();
44        let staging = dir.join(format!(".{base}.saving-{}", random_suffix()?));
45        create_dir_owner_only(&staging, false)?;
46        let result =
47            write_staged(&staging, path, &self.file_bytes()?, place).and_then(|()| sync_dir(dir));
48        let removed = std::fs::remove_dir_all(&staging);
49        result?;
50        removed.map_err(KeyFileError::from)
51    }
52
53    /// The key saved at `path` for `purpose` in `profile`, checked before it
54    /// is returned. A path that names anything but a regular file, directly
55    /// or through a symlink, is refused before it is opened, and the opened
56    /// file is checked again: a regular file, owned by the effective user,
57    /// that its group and others cannot read, of at most 64 KiB. Then a key
58    /// for another purpose or profile, halves that do not fit the profile, a
59    /// stored public key its private key does not derive, and a key that
60    /// fails a sign-and-verify round trip are refused.
61    pub fn load(path: &Path, purpose: Purpose, profile: Profile) -> Result<NodeKey, KeyFileError> {
62        let contents = read_key_file(path)?;
63        let key = parse(&contents, purpose, profile)?;
64        round_trip(&key)?;
65        Ok(key)
66    }
67
68    /// The identity key at `path` in `profile`, or, when nothing is there, a
69    /// new one with the admission puzzle solved, saved there first. Anything
70    /// at `path` that does not load as such a key is refused and left as it
71    /// is, never replaced. Two first starts at once end with one key: the
72    /// later one finds the file the earlier stored and loads it.
73    pub fn load_or_create(path: &Path, profile: Profile) -> Result<NodeKey, KeyFileError> {
74        match std::fs::symlink_metadata(path) {
75            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
76                let key = NodeKey::generate_identity(profile, super::PUZZLE_DIFFICULTY)
77                    .map_err(KeyFileError::Generate)?;
78                stored_first(key, path, profile)
79            }
80            _ => NodeKey::load(path, Purpose::Identity, profile),
81        }
82    }
83}
84
85/// `key`, stored at `path` where nothing is; or, when another first start
86/// stored one there meanwhile, that one.
87fn stored_first(key: NodeKey, path: &Path, profile: Profile) -> Result<NodeKey, KeyFileError> {
88    match key.save_new(path) {
89        Ok(()) => Ok(key),
90        Err(KeyFileError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
91            NodeKey::load(path, Purpose::Identity, profile)
92        }
93        Err(e) => Err(e),
94    }
95}
96
97fn random_suffix() -> Result<String, KeyFileError> {
98    let mut bytes = [0u8; 8];
99    aws_lc_rs::rand::fill(&mut bytes)
100        .map_err(|_| KeyFileError::Io(std::io::Error::other("no randomness")))?;
101    Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
102}
103
104/// How a staged key file takes its place: renamed over whatever is there, or
105/// linked where nothing is.
106#[derive(Clone, Copy)]
107enum Place {
108    Replace,
109    CreateNew,
110}
111
112fn write_staged(
113    staging: &Path,
114    path: &Path,
115    contents: &[u8],
116    place: Place,
117) -> Result<(), KeyFileError> {
118    let staged = staging.join("key");
119    let mut options = std::fs::OpenOptions::new();
120    options.write(true).create_new(true);
121    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
122    let mut file = options.open(&staged)?;
123    file.write_all(contents)?;
124    file.sync_all()?;
125    drop(file);
126    match place {
127        Place::Replace => std::fs::rename(&staged, path)?,
128        Place::CreateNew => std::fs::hard_link(&staged, path)?,
129    }
130    Ok(())
131}
132
133pub(super) fn create_dir_owner_only(dir: &Path, recursive: bool) -> Result<(), KeyFileError> {
134    let mut builder = std::fs::DirBuilder::new();
135    builder.recursive(recursive);
136    std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
137    builder.create(dir)?;
138    Ok(())
139}
140
141fn sync_dir(dir: &Path) -> Result<(), KeyFileError> {
142    std::fs::File::open(dir)?.sync_all()?;
143    Ok(())
144}
145
146/// The contents of the key file at `path`, read only once the path names a
147/// regular file and the opened file passes [`owner_only`].
148fn read_key_file(path: &Path) -> Result<Vec<u8>, KeyFileError> {
149    if !std::fs::metadata(path)?.is_file() {
150        return Err(KeyFileError::NotRegular);
151    }
152    let mut options = std::fs::OpenOptions::new();
153    options.read(true);
154    // Without waiting, should the path have become a FIFO since it was
155    // checked.
156    std::os::unix::fs::OpenOptionsExt::custom_flags(
157        &mut options,
158        rustix::fs::OFlags::NONBLOCK.bits() as i32,
159    );
160    let file = options.open(path)?;
161    owner_only(&file.metadata()?)?;
162    let mut contents = Vec::new();
163    file.take(MAX_KEY_FILE_BYTES + 1)
164        .read_to_end(&mut contents)?;
165    if contents.len() as u64 > MAX_KEY_FILE_BYTES {
166        return Err(KeyFileError::TooLarge);
167    }
168    Ok(contents)
169}
170
171/// Refuses an opened key file that is not a regular file, not the effective
172/// user's, or readable by its group or others.
173fn owner_only(metadata: &std::fs::Metadata) -> Result<(), KeyFileError> {
174    if !metadata.is_file() {
175        return Err(KeyFileError::NotRegular);
176    }
177    use std::os::unix::fs::MetadataExt;
178    if metadata.uid() != rustix::process::geteuid().as_raw() {
179        return Err(KeyFileError::Owner);
180    }
181    if metadata.mode() & 0o077 != 0 {
182        return Err(KeyFileError::Permissions);
183    }
184    Ok(())
185}