appcore_security/
secret_keyring.rs1use 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
20pub const FILE_SECRET_KEYRING_FORMAT: &str = "appcore-secret-keyring-v1";
22
23pub type SecretAccessResult<T> = Result<T, SecretAccessError>;
25
26#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
28pub enum SecretAccessError {
29 #[error("invalid secret keyring path or key identifier")]
31 InvalidPath,
32 #[error("secret keyring permissions are not owner-only")]
34 InsecurePermissions,
35 #[error("secret keyring material is unavailable")]
37 Unavailable,
38 #[error("secret keyring material is invalid")]
40 InvalidMaterial,
41 #[error("secret key has expired")]
43 Expired,
44 #[error("secret key is deprecated")]
46 Deprecated,
47 #[error("secret key was revoked")]
49 Revoked,
50 #[error("secret keyring state conflicts with the requested operation")]
52 Conflict,
53 #[error("secret keyring I/O failed")]
55 Io,
56}
57
58#[derive(Debug, Clone)]
60pub struct FileSecretKeyring {
61 root: PathBuf,
62 keys: PathBuf,
63 active: PathBuf,
64 lock: PathBuf,
65}
66
67impl FileSecretKeyring {
68 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 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 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 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 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 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 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;