im_core/internal/secret_vault/
store.rs1use 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}