Skip to main content

aven_core/sync/client/keys/
storage.rs

1//! Host persistence for protected local keys.
2use std::collections::HashSet;
3use std::fs::{self, File, OpenOptions};
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6
7use zeroize::Zeroizing;
8
9use super::{ProtectedLocalKeyStoreErrorKind, StoreResult, error};
10
11/// Names the package keyring among secret items. Every other secret item is
12/// named by its kind, such as `seed` or `peer-identity`.
13pub const KEYRING_ITEM: &str = "keyring";
14
15/// Holds a namespace's exclusive lock until dropped.
16pub struct ProtectedStorageLock {
17    _guard: Box<dyn Send + Sync>,
18}
19
20impl ProtectedStorageLock {
21    pub fn new(guard: impl Send + Sync + 'static) -> Self {
22        Self {
23            _guard: Box::new(guard),
24        }
25    }
26}
27
28/// Host-owned storage for protected local keys.
29///
30/// Every call names a namespace: a lowercase hex digest of the database's
31/// location and random `client_id`, so each database has its own items.
32/// Secret items hold key material and must stay out of database exports,
33/// backups and synchronizing credential stores. Records are nonsecret
34/// markers and public evidence. Both are opaque byte strings.
35///
36/// Creating an item or record never replaces an existing one. Absence is
37/// `Ok(None)`, never an error. Failures map to the stable error classes:
38/// `Unavailable` while storage can't be reached (for example, locked),
39/// `Corrupt` for unsafe or malformed contents and `WriteFailed` when a write
40/// or delete fails. Messages never contain key material or paths.
41pub trait ProtectedStorage: Send + Sync {
42    /// Stable bytes naming where the database lives. With its `client_id`
43    /// they name the namespace, so they must not change while the database
44    /// stays in place. The default is the canonical path, which suits hosts
45    /// whose paths are stable across launches and updates.
46    fn database_location(&self, database_path: &Path) -> StoreResult<Vec<u8>> {
47        canonical_location(database_path)
48    }
49
50    /// Makes the namespace ready before first use.
51    fn prepare(&self, namespace: &str) -> StoreResult<()>;
52
53    /// Excludes every other process and store using this namespace until the
54    /// returned lock is dropped.
55    fn lock(&self, namespace: &str) -> StoreResult<ProtectedStorageLock>;
56
57    /// The secret item, which callers expect to be `expected_len` bytes.
58    fn load_secret(
59        &self,
60        namespace: &str,
61        item: &str,
62        expected_len: usize,
63    ) -> StoreResult<Option<Zeroizing<Vec<u8>>>>;
64
65    /// Durably saves a secret item that the caller observed absent.
66    fn create_secret(&self, namespace: &str, item: &str, bytes: &[u8]) -> StoreResult<()>;
67
68    /// Deletes a secret item; an absent item is already deleted.
69    fn delete_secret(&self, namespace: &str, item: &str) -> StoreResult<()>;
70
71    /// The record, which callers expect to be `expected_len` bytes.
72    fn read_record(
73        &self,
74        namespace: &str,
75        name: &str,
76        expected_len: usize,
77    ) -> StoreResult<Option<Vec<u8>>>;
78
79    /// Durably saves a new record; an existing record is a failure.
80    fn create_record(&self, namespace: &str, name: &str, bytes: &[u8]) -> StoreResult<()>;
81
82    /// Removes a record; an absent record is already removed.
83    fn remove_record(&self, namespace: &str, name: &str) -> StoreResult<()>;
84
85    /// Names of every secret item in the namespace. Extra names are allowed
86    /// and only cost a load; a missing name makes its item read as absent.
87    /// Called under the namespace lock, once per lock.
88    fn list_secrets(&self, namespace: &str) -> StoreResult<HashSet<String>>;
89
90    /// Names of every record in the namespace, with the same contract as
91    /// [`Self::list_secrets`].
92    fn list_records(&self, namespace: &str) -> StoreResult<HashSet<String>>;
93}
94
95/// The canonical database path as bytes.
96pub fn canonical_location(database_path: &Path) -> StoreResult<Vec<u8>> {
97    let canonical = database_path
98        .canonicalize()
99        .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Unavailable))?;
100    #[cfg(unix)]
101    {
102        use std::os::unix::ffi::OsStrExt;
103        Ok(canonical.as_os_str().as_bytes().to_vec())
104    }
105    #[cfg(not(unix))]
106    {
107        Ok(canonical.to_string_lossy().as_bytes().to_vec())
108    }
109}
110
111/// Owner-only files in one directory. Secret item `kind` of namespace `ns`
112/// is `ns.kind`, the keyring is `ns.keyring`, record `name` is `ns.name` and
113/// the lock is `ns.lock`.
114pub struct FileProtectedStorage {
115    directory: PathBuf,
116}
117
118impl FileProtectedStorage {
119    pub fn new(directory: PathBuf) -> Self {
120        Self { directory }
121    }
122
123    pub fn directory(&self) -> &Path {
124        &self.directory
125    }
126
127    pub fn path(&self, namespace: &str, name: &str) -> PathBuf {
128        self.directory.join(format!("{namespace}.{name}"))
129    }
130}
131
132impl ProtectedStorage for FileProtectedStorage {
133    fn prepare(&self, _namespace: &str) -> StoreResult<()> {
134        prepare_directory(&self.directory)
135    }
136
137    fn lock(&self, namespace: &str) -> StoreResult<ProtectedStorageLock> {
138        let path = self.path(namespace, "lock");
139        let mut options = OpenOptions::new();
140        options.read(true).write(true).create(true).truncate(false);
141        #[cfg(unix)]
142        {
143            use std::os::unix::fs::OpenOptionsExt;
144            options.mode(0o600);
145        }
146        let file = options
147            .open(path)
148            .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Unavailable))?;
149        file.lock()
150            .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Unavailable))?;
151        Ok(ProtectedStorageLock::new(file))
152    }
153
154    fn load_secret(
155        &self,
156        namespace: &str,
157        item: &str,
158        expected_len: usize,
159    ) -> StoreResult<Option<Zeroizing<Vec<u8>>>> {
160        Ok(read_restricted_file(&self.path(namespace, item), expected_len)?.map(Zeroizing::new))
161    }
162
163    fn create_secret(&self, namespace: &str, item: &str, bytes: &[u8]) -> StoreResult<()> {
164        write_restricted_new(&self.path(namespace, item), bytes)
165    }
166
167    fn delete_secret(&self, namespace: &str, item: &str) -> StoreResult<()> {
168        remove_restricted_file(&self.path(namespace, item))
169    }
170
171    fn read_record(
172        &self,
173        namespace: &str,
174        name: &str,
175        expected_len: usize,
176    ) -> StoreResult<Option<Vec<u8>>> {
177        read_restricted_file(&self.path(namespace, name), expected_len)
178    }
179
180    fn create_record(&self, namespace: &str, name: &str, bytes: &[u8]) -> StoreResult<()> {
181        write_restricted_new(&self.path(namespace, name), bytes)
182    }
183
184    fn remove_record(&self, namespace: &str, name: &str) -> StoreResult<()> {
185        remove_restricted_file(&self.path(namespace, name))
186    }
187
188    /// Secret items and records share the directory, so both listings name
189    /// every file of the namespace except its lock.
190    fn list_secrets(&self, namespace: &str) -> StoreResult<HashSet<String>> {
191        self.list(namespace)
192    }
193
194    fn list_records(&self, namespace: &str) -> StoreResult<HashSet<String>> {
195        self.list(namespace)
196    }
197}
198
199impl FileProtectedStorage {
200    fn list(&self, namespace: &str) -> StoreResult<HashSet<String>> {
201        let prefix = format!("{namespace}.");
202        let entries = match fs::read_dir(&self.directory) {
203            Ok(entries) => entries,
204            Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
205                return Ok(HashSet::new());
206            }
207            Err(_) => return Err(error(ProtectedLocalKeyStoreErrorKind::Unavailable)),
208        };
209        let mut names = HashSet::new();
210        for entry in entries {
211            let entry = entry.map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Unavailable))?;
212            if let Some(name) = entry
213                .file_name()
214                .to_str()
215                .and_then(|name| name.strip_prefix(&prefix))
216                .filter(|name| *name != "lock")
217            {
218                names.insert(name.to_string());
219            }
220        }
221        Ok(names)
222    }
223}
224
225fn prepare_directory(path: &Path) -> StoreResult<()> {
226    fs::create_dir_all(path).map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Unavailable))?;
227    #[cfg(unix)]
228    {
229        use std::os::unix::fs::PermissionsExt;
230        fs::set_permissions(path, fs::Permissions::from_mode(0o700))
231            .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Unavailable))?;
232    }
233    Ok(())
234}
235
236fn read_restricted_file(path: &Path, expected_len: usize) -> StoreResult<Option<Vec<u8>>> {
237    let mut file = match File::open(path) {
238        Ok(file) => file,
239        Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(None),
240        Err(_) => return Err(error(ProtectedLocalKeyStoreErrorKind::Unavailable)),
241    };
242    let metadata = file
243        .metadata()
244        .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Unavailable))?;
245    if !metadata.is_file() || metadata.len() != expected_len as u64 {
246        return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
247    }
248    #[cfg(unix)]
249    {
250        use std::os::unix::fs::PermissionsExt;
251        if metadata.permissions().mode() & 0o077 != 0 {
252            return Err(error(ProtectedLocalKeyStoreErrorKind::Corrupt));
253        }
254    }
255    let mut bytes = vec![0_u8; expected_len];
256    file.read_exact(&mut bytes)
257        .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::Corrupt))?;
258    Ok(Some(bytes))
259}
260
261fn sync_parent(path: &Path) -> StoreResult<()> {
262    let parent = path
263        .parent()
264        .ok_or_else(|| error(ProtectedLocalKeyStoreErrorKind::WriteFailed))?;
265    File::open(parent)
266        .and_then(|directory| directory.sync_all())
267        .map_err(|_| error(ProtectedLocalKeyStoreErrorKind::WriteFailed))
268}
269
270fn remove_restricted_file(path: &Path) -> StoreResult<()> {
271    match fs::remove_file(path) {
272        Ok(()) => sync_parent(path),
273        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
274        Err(_) => Err(super::error(ProtectedLocalKeyStoreErrorKind::WriteFailed)),
275    }
276}
277
278fn write_failure(
279    phase: &'static str,
280    source: std::io::Error,
281) -> super::ProtectedLocalKeyStoreError {
282    tracing::warn!(
283        phase,
284        errno = source.raw_os_error(),
285        "protected storage write failed"
286    );
287    error(ProtectedLocalKeyStoreErrorKind::WriteFailed)
288}
289
290fn write_restricted_new(path: &Path, bytes: &[u8]) -> StoreResult<()> {
291    let parent = path
292        .parent()
293        .ok_or_else(|| error(ProtectedLocalKeyStoreErrorKind::WriteFailed))?;
294    let mut staged = tempfile::NamedTempFile::new_in(parent)
295        .map_err(|source| write_failure("stage_create", source))?;
296    #[cfg(unix)]
297    {
298        use std::os::unix::fs::PermissionsExt;
299        staged
300            .as_file()
301            .set_permissions(fs::Permissions::from_mode(0o600))
302            .map_err(|source| write_failure("stage_permissions", source))?;
303    }
304    staged
305        .as_file_mut()
306        .write_all(bytes)
307        .map_err(|source| write_failure("stage_write", source))?;
308    staged
309        .as_file()
310        .sync_all()
311        .map_err(|source| write_failure("stage_sync", source))?;
312    staged
313        .persist_noclobber(path)
314        .map_err(|source| write_failure("stage_persist", source.error))?;
315    File::open(parent)
316        .map_err(|source| write_failure("parent_open", source))?
317        .sync_all()
318        .map_err(|source| write_failure("parent_sync", source))
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn failed_create_preserves_existing_bytes_and_error_contract() {
327        let directory = tempfile::tempdir().unwrap();
328        let path = directory.path().join("record");
329        write_restricted_new(&path, b"first").unwrap();
330        assert_eq!(
331            write_restricted_new(&path, b"other").unwrap_err().kind(),
332            ProtectedLocalKeyStoreErrorKind::WriteFailed
333        );
334        assert_eq!(read_restricted_file(&path, 5).unwrap().unwrap(), b"first");
335        assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1);
336    }
337
338    #[test]
339    fn failed_staging_keeps_stable_private_error() {
340        let directory = tempfile::tempdir().unwrap();
341        let path = directory.path().join("missing/record");
342        let failure = write_restricted_new(&path, b"private").unwrap_err();
343        assert_eq!(failure.kind(), ProtectedLocalKeyStoreErrorKind::WriteFailed);
344        assert!(!failure.to_string().contains("missing"));
345        assert!(!failure.to_string().contains("private"));
346    }
347}