Skip to main content

appcore_security/
secret_keyring.rs

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