1use crate::{
14 format_secret_material, parse_secret_material, SecretBytes, SecretResolver,
15 SecuritySecretMaterial, SecuritySecretRef, SecuritySecretStatus,
16};
17use fs2::FileExt;
18use std::fmt;
19use std::fs::{self, File, OpenOptions};
20use std::path::{Path, PathBuf};
21
22#[path = "secret_keyring_fs.rs"]
23mod fs_support;
24use fs_support::{
25 atomic_write, create_private_directory, now_ms, open_lock, read_private_file, reject_symlink,
26 reject_unsafe_root, remove_file_if_present, set_private_file_permissions, sync_directory,
27 validate_private_directory, validate_private_file,
28};
29use zeroize::Zeroizing;
30
31#[cfg(windows)]
32#[path = "secret_dpapi.rs"]
33mod dpapi;
34
35pub const FILE_SECRET_KEYRING_FORMAT: &str = "appcore-secret-keyring-v1";
37
38#[cfg(windows)]
40pub const WINDOWS_DPAPI_USER_SECRET_KEYRING_FORMAT: &str =
41 "appcore-secret-keyring-windows-dpapi-user-v1";
42
43pub type SecretAccessResult<T> = Result<T, SecretAccessError>;
45
46#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
48pub enum SecretAccessError {
49 #[error("invalid secret keyring path or key identifier")]
51 InvalidPath,
52 #[error("secret keyring permissions are not owner-only")]
54 InsecurePermissions,
55 #[error("secret keyring material is unavailable")]
57 Unavailable,
58 #[error("secret keyring material is invalid")]
60 InvalidMaterial,
61 #[error("secret key has expired")]
63 Expired,
64 #[error("secret key is deprecated")]
66 Deprecated,
67 #[error("secret key was revoked")]
69 Revoked,
70 #[error("secret keyring state conflicts with the requested operation")]
72 Conflict,
73 #[error("secret keyring I/O failed")]
75 Io,
76}
77
78#[derive(Clone)]
80pub struct FileSecretKeyring {
81 root: PathBuf,
82 keys: PathBuf,
83 active: PathBuf,
84 lock: PathBuf,
85 protection: KeyProtection,
86}
87
88#[derive(Debug, Clone, Copy)]
89pub(crate) enum KeyProtection {
90 Plaintext,
91 #[cfg(windows)]
92 WindowsDpapiUser,
93}
94
95impl FileSecretKeyring {
96 pub fn open(root: impl Into<PathBuf>) -> SecretAccessResult<Self> {
98 Self::open_with(root.into(), KeyProtection::Plaintext)
99 }
100
101 pub(crate) fn open_with(root: PathBuf, protection: KeyProtection) -> SecretAccessResult<Self> {
102 reject_unsafe_root(&root)?;
103 create_private_directory(&root)?;
104 let keys = root.join("keys");
105 create_private_directory(&keys)?;
106 let keyring = Self {
107 active: root.join("active"),
108 lock: root.join("keyring.lock"),
109 root,
110 keys,
111 protection,
112 };
113 keyring.initialize_lock()?;
114 keyring.write_format_marker()?;
115 keyring.validate_layout()?;
116 Ok(keyring)
117 }
118
119 pub fn install_initial(&self, material: &SecuritySecretMaterial) -> SecretAccessResult<()> {
121 validate_new_active(material, now_ms())?;
122 let lock = self.lock_exclusive()?;
123 if self.active.exists() {
124 return Err(SecretAccessError::Conflict);
125 }
126 self.persist_key(material)?;
127 self.persist_active(&material.metadata.key_id)?;
128 FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)
129 }
130
131 pub fn rotate(
133 &self,
134 next: &SecuritySecretMaterial,
135 now_ms: u64,
136 ) -> SecretAccessResult<Option<String>> {
137 validate_new_active(next, now_ms)?;
138 let lock = self.lock_exclusive()?;
139 let previous = self.read_active_id().ok();
140 if previous.as_deref() == Some(next.metadata.key_id.as_str()) {
141 return Err(SecretAccessError::Conflict);
142 }
143 self.persist_key(next)?;
144 self.persist_active(&next.metadata.key_id)?;
145 if let Some(previous) = &previous {
146 let mut old = self.read_key(previous)?;
147 if old.metadata.status != SecuritySecretStatus::Revoked {
148 old.metadata.status = SecuritySecretStatus::Deprecated;
149 self.persist_key(&old)?;
150 }
151 }
152 FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)?;
153 Ok(previous)
154 }
155
156 pub fn revoke(&self, key_id: &str) -> SecretAccessResult<()> {
158 validate_key_id(key_id)?;
159 let lock = self.lock_exclusive()?;
160 let mut material = self.read_key(key_id)?;
161 material.metadata.status = SecuritySecretStatus::Revoked;
162 self.persist_key(&material)?;
163 if self.read_active_id().ok().as_deref() == Some(key_id) {
164 remove_file_if_present(&self.active)?;
165 sync_directory(&self.root)?;
166 }
167 FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)
168 }
169
170 pub fn resolve_active(&self, now_ms: u64) -> SecretAccessResult<SecuritySecretMaterial> {
172 let lock = self.lock_shared()?;
173 let material = self.read_key(&self.read_active_id()?)?;
174 validate_for_issue(&material, now_ms)?;
175 FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)?;
176 Ok(material)
177 }
178
179 pub fn resolve_for_validation(
181 &self,
182 key_id: &str,
183 now_ms: u64,
184 ) -> SecretAccessResult<SecuritySecretMaterial> {
185 validate_key_id(key_id)?;
186 let lock = self.lock_shared()?;
187 let material = self.read_key(key_id)?;
188 validate_for_validation(&material, now_ms)?;
189 FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)?;
190 Ok(material)
191 }
192
193 pub fn recover(&self, now_ms: u64) -> SecretAccessResult<String> {
195 let lock = self.lock_exclusive()?;
196 if let Ok(active) = self.read_active_id() {
197 validate_for_issue(&self.read_key(&active)?, now_ms)?;
198 return Ok(active);
199 }
200 let candidates = self.active_candidates(now_ms)?;
201 if candidates.len() != 1 {
202 return Err(SecretAccessError::Conflict);
203 }
204 self.persist_active(&candidates[0])?;
205 FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)?;
206 Ok(candidates[0].clone())
207 }
208
209 fn active_candidates(&self, now_ms: u64) -> SecretAccessResult<Vec<String>> {
210 let entries = fs::read_dir(&self.keys).map_err(|_| SecretAccessError::Io)?;
211 let mut candidates = Vec::new();
212 for entry in entries {
213 let entry = entry.map_err(|_| SecretAccessError::Io)?;
214 let path = entry.path();
215 if path.extension().and_then(|value| value.to_str()) != Some("secret") {
216 continue;
217 }
218 let material = self.read_material(&path)?;
219 if validate_for_issue(&material, now_ms).is_ok() {
220 candidates.push(material.metadata.key_id.clone());
221 }
222 }
223 candidates.sort();
224 Ok(candidates)
225 }
226
227 fn persist_key(&self, material: &SecuritySecretMaterial) -> SecretAccessResult<()> {
228 validate_key_id(&material.metadata.key_id)?;
229 let serialized = Zeroizing::new(format_secret_material(material).into_bytes());
230 let persisted = self.protection.protect(&serialized)?;
231 atomic_write(&self.key_path(&material.metadata.key_id), &persisted)
232 }
233
234 fn persist_active(&self, key_id: &str) -> SecretAccessResult<()> {
235 validate_key_id(key_id)?;
236 atomic_write(&self.active, format!("{key_id}\n").as_bytes())
237 }
238
239 fn read_active_id(&self) -> SecretAccessResult<String> {
240 let bytes = read_private_file(&self.active, 256)?;
241 let key_id = std::str::from_utf8(&bytes).map_err(|_| SecretAccessError::InvalidMaterial)?;
242 let key_id = key_id.trim();
243 validate_key_id(key_id)?;
244 Ok(key_id.to_string())
245 }
246
247 fn read_key(&self, key_id: &str) -> SecretAccessResult<SecuritySecretMaterial> {
248 validate_key_id(key_id)?;
249 let material = self.read_material(&self.key_path(key_id))?;
250 if material.metadata.key_id != key_id {
251 return Err(SecretAccessError::InvalidMaterial);
252 }
253 Ok(material)
254 }
255
256 fn read_material(&self, path: &Path) -> SecretAccessResult<SecuritySecretMaterial> {
257 let persisted = read_private_file(path, self.protection.max_persisted_bytes())?;
258 let plaintext = self.protection.unprotect(&persisted)?;
259 parse_secret_material(&plaintext).map_err(|_| SecretAccessError::InvalidMaterial)
260 }
261
262 fn key_path(&self, key_id: &str) -> PathBuf {
263 self.keys.join(format!("{key_id}.secret"))
264 }
265
266 fn initialize_lock(&self) -> SecretAccessResult<()> {
267 reject_symlink(&self.lock)?;
268 match OpenOptions::new()
269 .create_new(true)
270 .truncate(false)
271 .read(true)
272 .write(true)
273 .open(&self.lock)
274 {
275 Ok(file) => set_private_file_permissions(&self.lock, &file),
276 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
277 validate_private_file(&self.lock)
278 }
279 Err(_) => Err(SecretAccessError::Io),
280 }
281 }
282
283 fn write_format_marker(&self) -> SecretAccessResult<()> {
284 let marker = self.root.join("format");
285 if marker.exists() {
286 let existing = read_private_file(&marker, 128)?;
287 if existing != format!("{}\n", self.protection.format()).as_bytes() {
288 return Err(SecretAccessError::InvalidMaterial);
289 }
290 return Ok(());
291 }
292 atomic_write(
293 &marker,
294 format!("{}\n", self.protection.format()).as_bytes(),
295 )
296 }
297
298 fn validate_layout(&self) -> SecretAccessResult<()> {
299 validate_private_directory(&self.root)?;
300 validate_private_directory(&self.keys)?;
301 validate_private_file(&self.lock)
302 }
303
304 fn lock_exclusive(&self) -> SecretAccessResult<File> {
305 self.validate_layout()?;
306 let file = open_lock(&self.lock)?;
307 file.lock_exclusive().map_err(|_| SecretAccessError::Io)?;
308 Ok(file)
309 }
310
311 fn lock_shared(&self) -> SecretAccessResult<File> {
312 self.validate_layout()?;
313 let file = open_lock(&self.lock)?;
314 FileExt::lock_shared(&file).map_err(|_| SecretAccessError::Io)?;
315 Ok(file)
316 }
317}
318
319impl SecretResolver for FileSecretKeyring {
320 fn resolve(&self, reference: &SecuritySecretRef) -> crate::SecurityResult<SecretBytes> {
321 let now = now_ms();
322 let material = if reference.0 == "active" {
323 self.resolve_active(now)
324 } else {
325 self.resolve_for_validation(&reference.0, now)
326 }
327 .map_err(|_| crate::SecurityError::SecretUnavailable)?;
328 Ok(SecretBytes::new(material.secret.clone()))
329 }
330}
331
332impl fmt::Debug for FileSecretKeyring {
333 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
334 formatter
335 .debug_struct("FileSecretKeyring")
336 .field("format", &self.protection.format())
337 .finish_non_exhaustive()
338 }
339}
340
341impl KeyProtection {
342 fn format(self) -> &'static str {
343 match self {
344 Self::Plaintext => FILE_SECRET_KEYRING_FORMAT,
345 #[cfg(windows)]
346 Self::WindowsDpapiUser => WINDOWS_DPAPI_USER_SECRET_KEYRING_FORMAT,
347 }
348 }
349
350 fn max_persisted_bytes(self) -> u64 {
351 match self {
352 Self::Plaintext => 65_536,
353 #[cfg(windows)]
354 Self::WindowsDpapiUser => 131_072,
355 }
356 }
357
358 fn protect(self, plaintext: &[u8]) -> SecretAccessResult<Zeroizing<Vec<u8>>> {
359 match self {
360 Self::Plaintext => Ok(Zeroizing::new(plaintext.to_vec())),
361 #[cfg(windows)]
362 Self::WindowsDpapiUser => dpapi::protect(plaintext).map(Zeroizing::new),
363 }
364 }
365
366 fn unprotect(self, persisted: &[u8]) -> SecretAccessResult<Zeroizing<Vec<u8>>> {
367 match self {
368 Self::Plaintext => Ok(Zeroizing::new(persisted.to_vec())),
369 #[cfg(windows)]
370 Self::WindowsDpapiUser => dpapi::unprotect(persisted).map(Zeroizing::new),
371 }
372 }
373}
374
375fn validate_new_active(material: &SecuritySecretMaterial, now_ms: u64) -> SecretAccessResult<()> {
376 validate_key_id(&material.metadata.key_id)?;
377 if material.secret.len() < 16 {
378 return Err(SecretAccessError::InvalidMaterial);
379 }
380 validate_for_issue(material, now_ms)
381}
382
383fn validate_for_issue(material: &SecuritySecretMaterial, now_ms: u64) -> SecretAccessResult<()> {
384 validate_for_validation(material, now_ms)?;
385 if material.metadata.status == SecuritySecretStatus::Deprecated {
386 return Err(SecretAccessError::Deprecated);
387 }
388 Ok(())
389}
390
391fn validate_for_validation(
392 material: &SecuritySecretMaterial,
393 now_ms: u64,
394) -> SecretAccessResult<()> {
395 if material.metadata.status == SecuritySecretStatus::Revoked {
396 return Err(SecretAccessError::Revoked);
397 }
398 if material.is_expired(now_ms) {
399 return Err(SecretAccessError::Expired);
400 }
401 Ok(())
402}
403
404fn validate_key_id(key_id: &str) -> SecretAccessResult<()> {
405 if key_id.is_empty()
406 || key_id.len() > 128
407 || !key_id
408 .bytes()
409 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
410 {
411 return Err(SecretAccessError::InvalidPath);
412 }
413 Ok(())
414}
415
416impl fmt::Display for FileSecretKeyring {
417 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
418 formatter.write_str(FILE_SECRET_KEYRING_FORMAT)
419 }
420}
421
422#[cfg(test)]
423#[path = "secret_keyring_tests.rs"]
424mod tests;