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};
29
30/// Stable persisted format identifier for the file keyring.
31pub const FILE_SECRET_KEYRING_FORMAT: &str = "appcore-secret-keyring-v1";
32
33/// Result returned by file-keyring operations.
34pub type SecretAccessResult<T> = Result<T, SecretAccessError>;
35
36/// Typed file-keyring policy and persistence failures.
37#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38pub enum SecretAccessError {
39    /// The root or key identifier is unsafe.
40    #[error("invalid secret keyring path or key identifier")]
41    InvalidPath,
42    /// Owner-only permission requirements are not met.
43    #[error("secret keyring permissions are not owner-only")]
44    InsecurePermissions,
45    /// The requested key or active pointer is unavailable.
46    #[error("secret keyring material is unavailable")]
47    Unavailable,
48    /// Persisted material is malformed or partially written.
49    #[error("secret keyring material is invalid")]
50    InvalidMaterial,
51    /// The requested key has expired.
52    #[error("secret key has expired")]
53    Expired,
54    /// The requested key is deprecated and cannot issue new credentials.
55    #[error("secret key is deprecated")]
56    Deprecated,
57    /// The requested key was revoked.
58    #[error("secret key was revoked")]
59    Revoked,
60    /// The operation conflicts with existing keyring state.
61    #[error("secret keyring state conflicts with the requested operation")]
62    Conflict,
63    /// An operating-system persistence operation failed.
64    #[error("secret keyring I/O failed")]
65    Io,
66}
67
68/// Owner-only, process-safe secret keyring for one deployment directory.
69#[derive(Debug, Clone)]
70pub struct FileSecretKeyring {
71    root: PathBuf,
72    keys: PathBuf,
73    active: PathBuf,
74    lock: PathBuf,
75}
76
77impl FileSecretKeyring {
78    /// Opens or creates a V1 keyring rooted at `root`.
79    pub fn open(root: impl Into<PathBuf>) -> SecretAccessResult<Self> {
80        let root = root.into();
81        reject_unsafe_root(&root)?;
82        create_private_directory(&root)?;
83        let keys = root.join("keys");
84        create_private_directory(&keys)?;
85        let keyring = Self {
86            active: root.join("active"),
87            lock: root.join("keyring.lock"),
88            root,
89            keys,
90        };
91        keyring.initialize_lock()?;
92        keyring.write_format_marker()?;
93        keyring.validate_layout()?;
94        Ok(keyring)
95    }
96
97    /// Installs the first active key without replacing an existing keyring.
98    pub fn install_initial(&self, material: &SecuritySecretMaterial) -> SecretAccessResult<()> {
99        validate_new_active(material, now_ms())?;
100        let lock = self.lock_exclusive()?;
101        if self.active.exists() {
102            return Err(SecretAccessError::Conflict);
103        }
104        self.persist_key(material)?;
105        self.persist_active(&material.metadata.key_id)?;
106        FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)
107    }
108
109    /// Atomically selects `next` before deprecating the previous active key.
110    pub fn rotate(
111        &self,
112        next: &SecuritySecretMaterial,
113        now_ms: u64,
114    ) -> SecretAccessResult<Option<String>> {
115        validate_new_active(next, now_ms)?;
116        let lock = self.lock_exclusive()?;
117        let previous = self.read_active_id().ok();
118        if previous.as_deref() == Some(next.metadata.key_id.as_str()) {
119            return Err(SecretAccessError::Conflict);
120        }
121        self.persist_key(next)?;
122        self.persist_active(&next.metadata.key_id)?;
123        if let Some(previous) = &previous {
124            let mut old = self.read_key(previous)?;
125            if old.metadata.status != SecuritySecretStatus::Revoked {
126                old.metadata.status = SecuritySecretStatus::Deprecated;
127                self.persist_key(&old)?;
128            }
129        }
130        FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)?;
131        Ok(previous)
132    }
133
134    /// Revokes a key and removes the active pointer when it selected that key.
135    pub fn revoke(&self, key_id: &str) -> SecretAccessResult<()> {
136        validate_key_id(key_id)?;
137        let lock = self.lock_exclusive()?;
138        let mut material = self.read_key(key_id)?;
139        material.metadata.status = SecuritySecretStatus::Revoked;
140        self.persist_key(&material)?;
141        if self.read_active_id().ok().as_deref() == Some(key_id) {
142            remove_file_if_present(&self.active)?;
143            sync_directory(&self.root)?;
144        }
145        FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)
146    }
147
148    /// Resolves the active key for issuing new credentials.
149    pub fn resolve_active(&self, now_ms: u64) -> SecretAccessResult<SecuritySecretMaterial> {
150        let lock = self.lock_shared()?;
151        let material = self.read_key(&self.read_active_id()?)?;
152        validate_for_issue(&material, now_ms)?;
153        FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)?;
154        Ok(material)
155    }
156
157    /// Resolves an active or deprecated key for validating existing credentials.
158    pub fn resolve_for_validation(
159        &self,
160        key_id: &str,
161        now_ms: u64,
162    ) -> SecretAccessResult<SecuritySecretMaterial> {
163        validate_key_id(key_id)?;
164        let lock = self.lock_shared()?;
165        let material = self.read_key(key_id)?;
166        validate_for_validation(&material, now_ms)?;
167        FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)?;
168        Ok(material)
169    }
170
171    /// Repairs an absent active pointer when exactly one usable active key exists.
172    pub fn recover(&self, now_ms: u64) -> SecretAccessResult<String> {
173        let lock = self.lock_exclusive()?;
174        if let Ok(active) = self.read_active_id() {
175            validate_for_issue(&self.read_key(&active)?, now_ms)?;
176            return Ok(active);
177        }
178        let candidates = self.active_candidates(now_ms)?;
179        if candidates.len() != 1 {
180            return Err(SecretAccessError::Conflict);
181        }
182        self.persist_active(&candidates[0])?;
183        FileExt::unlock(&lock).map_err(|_| SecretAccessError::Io)?;
184        Ok(candidates[0].clone())
185    }
186
187    fn active_candidates(&self, now_ms: u64) -> SecretAccessResult<Vec<String>> {
188        let entries = fs::read_dir(&self.keys).map_err(|_| SecretAccessError::Io)?;
189        let mut candidates = Vec::new();
190        for entry in entries {
191            let entry = entry.map_err(|_| SecretAccessError::Io)?;
192            let path = entry.path();
193            if path.extension().and_then(|value| value.to_str()) != Some("secret") {
194                continue;
195            }
196            let material = read_material(&path)?;
197            if validate_for_issue(&material, now_ms).is_ok() {
198                candidates.push(material.metadata.key_id.clone());
199            }
200        }
201        candidates.sort();
202        Ok(candidates)
203    }
204
205    fn persist_key(&self, material: &SecuritySecretMaterial) -> SecretAccessResult<()> {
206        validate_key_id(&material.metadata.key_id)?;
207        atomic_write(
208            &self.key_path(&material.metadata.key_id),
209            format_secret_material(material).as_bytes(),
210        )
211    }
212
213    fn persist_active(&self, key_id: &str) -> SecretAccessResult<()> {
214        validate_key_id(key_id)?;
215        atomic_write(&self.active, format!("{key_id}\n").as_bytes())
216    }
217
218    fn read_active_id(&self) -> SecretAccessResult<String> {
219        let bytes = read_private_file(&self.active, 256)?;
220        let key_id = std::str::from_utf8(&bytes).map_err(|_| SecretAccessError::InvalidMaterial)?;
221        let key_id = key_id.trim();
222        validate_key_id(key_id)?;
223        Ok(key_id.to_string())
224    }
225
226    fn read_key(&self, key_id: &str) -> SecretAccessResult<SecuritySecretMaterial> {
227        validate_key_id(key_id)?;
228        read_material(&self.key_path(key_id))
229    }
230
231    fn key_path(&self, key_id: &str) -> PathBuf {
232        self.keys.join(format!("{key_id}.secret"))
233    }
234
235    fn initialize_lock(&self) -> SecretAccessResult<()> {
236        reject_symlink(&self.lock)?;
237        let file = OpenOptions::new()
238            .create(true)
239            .truncate(false)
240            .read(true)
241            .write(true)
242            .open(&self.lock)
243            .map_err(|_| SecretAccessError::Io)?;
244        set_private_file_permissions(&file)?;
245        Ok(())
246    }
247
248    fn write_format_marker(&self) -> SecretAccessResult<()> {
249        let marker = self.root.join("format");
250        if marker.exists() {
251            let existing = read_private_file(&marker, 128)?;
252            if existing != format!("{FILE_SECRET_KEYRING_FORMAT}\n").as_bytes() {
253                return Err(SecretAccessError::InvalidMaterial);
254            }
255            return Ok(());
256        }
257        atomic_write(
258            &marker,
259            format!("{FILE_SECRET_KEYRING_FORMAT}\n").as_bytes(),
260        )
261    }
262
263    fn validate_layout(&self) -> SecretAccessResult<()> {
264        validate_private_directory(&self.root)?;
265        validate_private_directory(&self.keys)?;
266        validate_private_file(&self.lock)
267    }
268
269    fn lock_exclusive(&self) -> SecretAccessResult<File> {
270        self.validate_layout()?;
271        let file = open_lock(&self.lock)?;
272        file.lock_exclusive().map_err(|_| SecretAccessError::Io)?;
273        Ok(file)
274    }
275
276    fn lock_shared(&self) -> SecretAccessResult<File> {
277        self.validate_layout()?;
278        let file = open_lock(&self.lock)?;
279        FileExt::lock_shared(&file).map_err(|_| SecretAccessError::Io)?;
280        Ok(file)
281    }
282}
283
284impl SecretResolver for FileSecretKeyring {
285    fn resolve(&self, reference: &SecuritySecretRef) -> crate::SecurityResult<SecretBytes> {
286        let now = now_ms();
287        let material = if reference.0 == "active" {
288            self.resolve_active(now)
289        } else {
290            self.resolve_for_validation(&reference.0, now)
291        }
292        .map_err(|_| crate::SecurityError::SecretUnavailable)?;
293        Ok(SecretBytes::new(material.secret.clone()))
294    }
295}
296
297fn read_material(path: &Path) -> SecretAccessResult<SecuritySecretMaterial> {
298    let bytes = read_private_file(path, 65_536)?;
299    parse_secret_material(&bytes).map_err(|_| SecretAccessError::InvalidMaterial)
300}
301
302fn validate_new_active(material: &SecuritySecretMaterial, now_ms: u64) -> SecretAccessResult<()> {
303    validate_key_id(&material.metadata.key_id)?;
304    if material.secret.len() < 16 {
305        return Err(SecretAccessError::InvalidMaterial);
306    }
307    validate_for_issue(material, now_ms)
308}
309
310fn validate_for_issue(material: &SecuritySecretMaterial, now_ms: u64) -> SecretAccessResult<()> {
311    validate_for_validation(material, now_ms)?;
312    if material.metadata.status == SecuritySecretStatus::Deprecated {
313        return Err(SecretAccessError::Deprecated);
314    }
315    Ok(())
316}
317
318fn validate_for_validation(
319    material: &SecuritySecretMaterial,
320    now_ms: u64,
321) -> SecretAccessResult<()> {
322    if material.metadata.status == SecuritySecretStatus::Revoked {
323        return Err(SecretAccessError::Revoked);
324    }
325    if material.is_expired(now_ms) {
326        return Err(SecretAccessError::Expired);
327    }
328    Ok(())
329}
330
331fn validate_key_id(key_id: &str) -> SecretAccessResult<()> {
332    if key_id.is_empty()
333        || key_id.len() > 128
334        || !key_id
335            .bytes()
336            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
337    {
338        return Err(SecretAccessError::InvalidPath);
339    }
340    Ok(())
341}
342
343impl fmt::Display for FileSecretKeyring {
344    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
345        formatter.write_str(FILE_SECRET_KEYRING_FORMAT)
346    }
347}
348
349#[cfg(test)]
350#[path = "secret_keyring_tests.rs"]
351mod tests;