Skip to main content

appcore_security/
secret_keyring.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: secret_keyring.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/23 23:50:45 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 23:50:45 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Durable rotation-aware secret storage for deployment-local use.
12
13use 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
35/// Stable persisted format identifier for the file keyring.
36pub const FILE_SECRET_KEYRING_FORMAT: &str = "appcore-secret-keyring-v1";
37
38/// Stable persisted format identifier for the Windows DPAPI user keyring.
39#[cfg(windows)]
40pub const WINDOWS_DPAPI_USER_SECRET_KEYRING_FORMAT: &str =
41    "appcore-secret-keyring-windows-dpapi-user-v1";
42
43/// Result returned by file-keyring operations.
44pub type SecretAccessResult<T> = Result<T, SecretAccessError>;
45
46/// Typed file-keyring policy and persistence failures.
47#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
48pub enum SecretAccessError {
49    /// The root or key identifier is unsafe.
50    #[error("invalid secret keyring path or key identifier")]
51    InvalidPath,
52    /// Owner-only permission requirements are not met.
53    #[error("secret keyring permissions are not owner-only")]
54    InsecurePermissions,
55    /// The requested key or active pointer is unavailable.
56    #[error("secret keyring material is unavailable")]
57    Unavailable,
58    /// Persisted material is malformed or partially written.
59    #[error("secret keyring material is invalid")]
60    InvalidMaterial,
61    /// The requested key has expired.
62    #[error("secret key has expired")]
63    Expired,
64    /// The requested key is deprecated and cannot issue new credentials.
65    #[error("secret key is deprecated")]
66    Deprecated,
67    /// The requested key was revoked.
68    #[error("secret key was revoked")]
69    Revoked,
70    /// The operation conflicts with existing keyring state.
71    #[error("secret keyring state conflicts with the requested operation")]
72    Conflict,
73    /// An operating-system persistence operation failed.
74    #[error("secret keyring I/O failed")]
75    Io,
76}
77
78/// Owner-only, process-safe secret keyring for one deployment directory.
79#[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    /// Opens or creates a V1 keyring rooted at `root`.
97    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    /// Installs the first active key without replacing an existing keyring.
120    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    /// Atomically selects `next` before deprecating the previous active key.
132    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    /// Revokes a key and removes the active pointer when it selected that key.
157    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    /// Resolves the active key for issuing new credentials.
171    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    /// Resolves an active or deprecated key for validating existing credentials.
180    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    /// Repairs an absent active pointer when exactly one usable active key exists.
194    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;