Skip to main content

im_core/internal/secret_vault/
store.rs

1use super::record::{SecretRef, VaultSecretRecord};
2use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
3use sha2::{Digest, Sha256};
4use std::fs;
5use std::io::Write;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone)]
9pub struct FileSecretVaultStore {
10    root_dir: PathBuf,
11}
12
13impl FileSecretVaultStore {
14    pub fn new(root_dir: impl Into<PathBuf>) -> Self {
15        Self {
16            root_dir: root_dir.into(),
17        }
18    }
19
20    pub(crate) fn put(&self, record: &VaultSecretRecord) -> crate::ImResult<SecretRef> {
21        let secret_ref = record.secret_ref();
22        let path = self.record_path(&secret_ref);
23        let raw =
24            serde_json::to_vec_pretty(record).map_err(|err| crate::ImError::Serialization {
25                detail: err.to_string(),
26            })?;
27        write_secure_file(&path, &raw)?;
28        Ok(secret_ref)
29    }
30
31    pub(crate) fn get(&self, secret_ref: &SecretRef) -> crate::ImResult<VaultSecretRecord> {
32        let path = self.record_path(secret_ref);
33        let raw = fs::read(&path).map_err(|err| crate::ImError::CredentialFileUnreadable {
34            path_kind: "secret_vault_record".to_owned(),
35            detail: err.to_string(),
36        })?;
37        let record: VaultSecretRecord =
38            serde_json::from_slice(&raw).map_err(|err| crate::ImError::Serialization {
39                detail: err.to_string(),
40            })?;
41        if record.secret_ref() != *secret_ref {
42            return Err(crate::ImError::PermissionDenied);
43        }
44        Ok(record)
45    }
46
47    pub(crate) fn delete(&self, secret_ref: &SecretRef) -> crate::ImResult<()> {
48        let path = self.record_path(secret_ref);
49        match fs::remove_file(&path) {
50            Ok(()) => Ok(()),
51            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
52            Err(err) => Err(crate::ImError::Io {
53                detail: format!("delete secret vault record {}: {err}", path.display()),
54            }),
55        }
56    }
57
58    pub(crate) fn list(&self) -> crate::ImResult<Vec<SecretRef>> {
59        let records_dir = self.records_dir();
60        let entries = match fs::read_dir(&records_dir) {
61            Ok(entries) => entries,
62            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
63            Err(err) => {
64                return Err(crate::ImError::Io {
65                    detail: format!("read secret vault records {}: {err}", records_dir.display()),
66                });
67            }
68        };
69        let mut refs = Vec::new();
70        for entry in entries {
71            let entry = entry.map_err(crate::ImError::from)?;
72            if !is_record_file(&entry.path()) {
73                continue;
74            }
75            if entry.file_type().map_err(crate::ImError::from)?.is_file() {
76                let raw = fs::read(entry.path()).map_err(crate::ImError::from)?;
77                let record: VaultSecretRecord =
78                    serde_json::from_slice(&raw).map_err(|err| crate::ImError::Serialization {
79                        detail: err.to_string(),
80                    })?;
81                refs.push(record.secret_ref());
82            }
83        }
84        refs.sort_by(|left, right| {
85            record_storage_key(left)
86                .as_str()
87                .cmp(record_storage_key(right).as_str())
88        });
89        Ok(refs)
90    }
91
92    pub(crate) fn record_path(&self, secret_ref: &SecretRef) -> PathBuf {
93        self.records_dir()
94            .join(format!("{}.json", record_storage_key(secret_ref)))
95    }
96
97    fn records_dir(&self) -> PathBuf {
98        self.root_dir.join("records")
99    }
100}
101
102fn is_record_file(path: &Path) -> bool {
103    path.extension().and_then(|value| value.to_str()) == Some("json")
104}
105
106fn record_storage_key(secret_ref: &SecretRef) -> String {
107    let mut hasher = Sha256::new();
108    push_hash_field(&mut hasher, "workspace_id", &secret_ref.workspace_id);
109    push_hash_field(&mut hasher, "device_id", &secret_ref.device_id);
110    push_optional_hash_field(
111        &mut hasher,
112        "identity_id",
113        secret_ref.identity_id.as_deref(),
114    );
115    push_optional_hash_field(&mut hasher, "did", secret_ref.did.as_deref());
116    push_hash_field(&mut hasher, "kind", secret_ref.kind.as_str());
117    push_hash_field(&mut hasher, "key_id", &secret_ref.key_id);
118    push_hash_field(
119        &mut hasher,
120        "key_version",
121        &secret_ref.key_version.to_string(),
122    );
123    URL_SAFE_NO_PAD.encode(hasher.finalize())
124}
125
126fn push_optional_hash_field(hasher: &mut Sha256, name: &str, value: Option<&str>) {
127    match value {
128        Some(value) => push_hash_field(hasher, name, value),
129        None => push_hash_field(hasher, name, "<none>"),
130    }
131}
132
133fn push_hash_field(hasher: &mut Sha256, name: &str, value: &str) {
134    hasher.update(name.as_bytes());
135    hasher.update([0]);
136    hasher.update(value.len().to_string().as_bytes());
137    hasher.update([0]);
138    hasher.update(value.as_bytes());
139    hasher.update([0xff]);
140}
141
142fn write_secure_file(path: &Path, raw: &[u8]) -> crate::ImResult<()> {
143    if let Some(parent) = path.parent() {
144        fs::create_dir_all(parent)?;
145        set_private_dir_mode(parent)?;
146    }
147    let temp = temp_path(path);
148    {
149        let mut file = create_private_file(&temp)?;
150        file.write_all(raw).map_err(crate::ImError::from)?;
151        file.sync_all().map_err(crate::ImError::from)?;
152    }
153    fs::rename(&temp, path).map_err(|err| crate::ImError::Io {
154        detail: format!(
155            "rename secret vault temp file {} to {}: {err}",
156            temp.display(),
157            path.display()
158        ),
159    })?;
160    set_private_file_mode(path)?;
161    Ok(())
162}
163
164fn temp_path(path: &Path) -> PathBuf {
165    let nanos = std::time::SystemTime::now()
166        .duration_since(std::time::UNIX_EPOCH)
167        .map(|duration| duration.as_nanos())
168        .unwrap_or(0);
169    let file_name = path
170        .file_name()
171        .and_then(|name| name.to_str())
172        .unwrap_or("record.json");
173    path.with_file_name(format!(".{file_name}.{}.{}.tmp", std::process::id(), nanos))
174}
175
176#[cfg(unix)]
177fn create_private_file(path: &Path) -> crate::ImResult<fs::File> {
178    use std::os::unix::fs::OpenOptionsExt;
179    fs::OpenOptions::new()
180        .create_new(true)
181        .write(true)
182        .mode(0o600)
183        .open(path)
184        .map_err(crate::ImError::from)
185}
186
187#[cfg(not(unix))]
188fn create_private_file(path: &Path) -> crate::ImResult<fs::File> {
189    fs::OpenOptions::new()
190        .create_new(true)
191        .write(true)
192        .open(path)
193        .map_err(crate::ImError::from)
194}
195
196#[cfg(unix)]
197fn set_private_dir_mode(path: &Path) -> crate::ImResult<()> {
198    use std::os::unix::fs::PermissionsExt;
199    fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
200    Ok(())
201}
202
203#[cfg(not(unix))]
204fn set_private_dir_mode(_path: &Path) -> crate::ImResult<()> {
205    Ok(())
206}
207
208#[cfg(unix)]
209fn set_private_file_mode(path: &Path) -> crate::ImResult<()> {
210    use std::os::unix::fs::PermissionsExt;
211    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
212    Ok(())
213}
214
215#[cfg(not(unix))]
216fn set_private_file_mode(_path: &Path) -> crate::ImResult<()> {
217    Ok(())
218}