appcore_security/
secret_keyring.rs1use 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
30pub const FILE_SECRET_KEYRING_FORMAT: &str = "appcore-secret-keyring-v1";
32
33pub type SecretAccessResult<T> = Result<T, SecretAccessError>;
35
36#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
38pub enum SecretAccessError {
39 #[error("invalid secret keyring path or key identifier")]
41 InvalidPath,
42 #[error("secret keyring permissions are not owner-only")]
44 InsecurePermissions,
45 #[error("secret keyring material is unavailable")]
47 Unavailable,
48 #[error("secret keyring material is invalid")]
50 InvalidMaterial,
51 #[error("secret key has expired")]
53 Expired,
54 #[error("secret key is deprecated")]
56 Deprecated,
57 #[error("secret key was revoked")]
59 Revoked,
60 #[error("secret keyring state conflicts with the requested operation")]
62 Conflict,
63 #[error("secret keyring I/O failed")]
65 Io,
66}
67
68#[derive(Debug, Clone)]
70pub struct FileSecretKeyring {
71 root: PathBuf,
72 keys: PathBuf,
73 active: PathBuf,
74 lock: PathBuf,
75}
76
77impl FileSecretKeyring {
78 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 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 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 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 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 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 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;