1use std::{
4 collections::BTreeMap,
5 error, fmt,
6 fs::{self, File, OpenOptions},
7 io::{Read, Write},
8 path::Path,
9};
10
11pub use age::secrecy::{ExposeSecret, SecretString};
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14use zeroize::{Zeroize, Zeroizing};
15
16const VAULT_VERSION: u32 = 1;
17const MAX_SECRET_NAME_BYTES: usize = 128;
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21#[non_exhaustive]
22pub enum ErrorKind {
23 InvalidInput,
24 Decryption,
25 InvalidData,
26 UnsupportedVersion,
27 Storage,
28}
29
30pub struct Error {
32 kind: ErrorKind,
33 message: String,
34}
35
36impl Error {
37 fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
38 Self {
39 kind,
40 message: message.into(),
41 }
42 }
43
44 pub fn kind(&self) -> ErrorKind {
46 self.kind
47 }
48}
49
50impl fmt::Debug for Error {
51 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52 formatter
53 .debug_struct("Error")
54 .field("kind", &self.kind)
55 .field("message", &self.message)
56 .finish()
57 }
58}
59
60impl fmt::Display for Error {
61 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62 formatter.write_str(&self.message)
63 }
64}
65
66impl error::Error for Error {}
67
68pub type Result<T> = std::result::Result<T, Error>;
69
70#[derive(Deserialize)]
71struct VaultPayload {
72 version: u32,
73 secrets: BTreeMap<String, String>,
74}
75
76impl Drop for VaultPayload {
77 fn drop(&mut self) {
78 zeroize_values(&mut self.secrets);
79 }
80}
81
82#[derive(Serialize)]
83struct VaultPayloadRef<'a> {
84 version: u32,
85 secrets: &'a BTreeMap<String, String>,
86}
87
88pub struct CredentialVault {
90 secrets: BTreeMap<String, String>,
91}
92
93impl CredentialVault {
94 pub fn empty() -> Self {
96 Self {
97 secrets: BTreeMap::new(),
98 }
99 }
100
101 pub fn unlock(path: &Path, passphrase: SecretString) -> Result<Self> {
103 let ciphertext = fs::read(path).map_err(|error| {
104 storage_error(
105 format!("reading credential vault {}", path.display()),
106 error,
107 )
108 })?;
109 let decryptor = age::Decryptor::new(&ciphertext[..]).map_err(|_| {
110 Error::new(
111 ErrorKind::Decryption,
112 "reading encrypted credential vault failed",
113 )
114 })?;
115 let identity = age::scrypt::Identity::new(passphrase);
116 let mut reader = decryptor
117 .decrypt(std::iter::once(&identity as &dyn age::Identity))
118 .map_err(|_| {
119 Error::new(
120 ErrorKind::Decryption,
121 "unlocking credential vault failed; the passphrase may be incorrect",
122 )
123 })?;
124 let mut plaintext = Zeroizing::new(Vec::new());
125 reader
126 .read_to_end(&mut plaintext)
127 .map_err(|_| Error::new(ErrorKind::Decryption, "decrypting credential vault failed"))?;
128 let mut payload: VaultPayload = serde_json::from_slice(&plaintext).map_err(|error| {
129 Error::new(
130 ErrorKind::InvalidData,
131 format!("parsing decrypted credential vault failed: {error}"),
132 )
133 })?;
134 if payload.version != VAULT_VERSION {
135 return Err(Error::new(
136 ErrorKind::UnsupportedVersion,
137 format!(
138 "credential vault version {} is unsupported",
139 payload.version
140 ),
141 ));
142 }
143 validate_stored_secrets(&payload.secrets)?;
144 Ok(Self {
145 secrets: std::mem::take(&mut payload.secrets),
146 })
147 }
148
149 pub fn save(&self, path: &Path, passphrase: &SecretString) -> Result<()> {
151 let payload = VaultPayloadRef {
152 version: VAULT_VERSION,
153 secrets: &self.secrets,
154 };
155 let plaintext = Zeroizing::new(serde_json::to_vec(&payload).map_err(|error| {
156 Error::new(
157 ErrorKind::InvalidData,
158 format!("serializing credential vault failed: {error}"),
159 )
160 })?);
161 let ciphertext = encrypt(&plaintext, passphrase)?;
162 write_private_atomic(path, &ciphertext)
163 }
164
165 pub fn set(&mut self, name: &str, mut value: String) -> Result<()> {
167 if let Err(error) = validate_secret_name(name) {
168 value.zeroize();
169 return Err(error);
170 }
171 if value.is_empty() {
172 value.zeroize();
173 return Err(Error::new(
174 ErrorKind::InvalidInput,
175 "secret values cannot be empty",
176 ));
177 }
178 if let Some(mut previous) = self.secrets.insert(name.to_owned(), value) {
179 previous.zeroize();
180 }
181 Ok(())
182 }
183
184 pub fn remove(&mut self, name: &str) -> Result<bool> {
186 validate_secret_name(name)?;
187 if let Some(mut value) = self.secrets.remove(name) {
188 value.zeroize();
189 Ok(true)
190 } else {
191 Ok(false)
192 }
193 }
194
195 pub fn secret(&self, name: &str) -> Result<Option<SecretString>> {
197 validate_secret_name(name)?;
198 Ok(self.secrets.get(name).cloned().map(SecretString::from))
199 }
200
201 pub fn names(&self) -> impl Iterator<Item = &str> {
203 self.secrets.keys().map(String::as_str)
204 }
205}
206
207impl Default for CredentialVault {
208 fn default() -> Self {
209 Self::empty()
210 }
211}
212
213impl fmt::Debug for CredentialVault {
214 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215 formatter
216 .debug_struct("CredentialVault")
217 .field("secret_count", &self.secrets.len())
218 .field("secrets", &"[REDACTED]")
219 .finish()
220 }
221}
222
223impl Drop for CredentialVault {
224 fn drop(&mut self) {
225 zeroize_values(&mut self.secrets);
226 }
227}
228
229fn zeroize_values(values: &mut BTreeMap<String, String>) {
230 for value in values.values_mut() {
231 value.zeroize();
232 }
233}
234
235fn valid_secret_name(name: &str) -> bool {
236 !name.is_empty()
237 && name.len() <= MAX_SECRET_NAME_BYTES
238 && name
239 .bytes()
240 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
241}
242
243fn validate_secret_name(name: &str) -> Result<()> {
244 if !valid_secret_name(name) {
245 return Err(Error::new(
246 ErrorKind::InvalidInput,
247 "secret names must contain 1-128 ASCII letters, numbers, dots, dashes, or underscores",
248 ));
249 }
250 Ok(())
251}
252
253fn validate_stored_secrets(secrets: &BTreeMap<String, String>) -> Result<()> {
254 if secrets
255 .iter()
256 .any(|(name, value)| !valid_secret_name(name) || value.is_empty())
257 {
258 return Err(Error::new(
259 ErrorKind::InvalidData,
260 "the decrypted credential vault contains an invalid secret entry",
261 ));
262 }
263 Ok(())
264}
265
266fn encrypt(plaintext: &[u8], passphrase: &SecretString) -> Result<Vec<u8>> {
267 let encryptor = age::Encryptor::with_user_passphrase(passphrase.clone());
268 let mut ciphertext = Vec::new();
269 {
270 let mut writer = encryptor.wrap_output(&mut ciphertext).map_err(|_| {
271 Error::new(
272 ErrorKind::InvalidData,
273 "starting credential vault encryption failed",
274 )
275 })?;
276 writer.write_all(plaintext).map_err(|_| {
277 Error::new(ErrorKind::InvalidData, "encrypting credential vault failed")
278 })?;
279 writer.finish().map_err(|_| {
280 Error::new(
281 ErrorKind::InvalidData,
282 "finishing credential vault encryption failed",
283 )
284 })?;
285 }
286 Ok(ciphertext)
287}
288
289fn write_private_atomic(path: &Path, contents: &[u8]) -> Result<()> {
290 let file_name = path.file_name().ok_or_else(|| {
291 Error::new(
292 ErrorKind::InvalidInput,
293 "credential vault path must name a file",
294 )
295 })?;
296 if file_name.is_empty() {
297 return Err(Error::new(
298 ErrorKind::InvalidInput,
299 "credential vault path must name a file",
300 ));
301 }
302 let parent = path
303 .parent()
304 .filter(|value| !value.as_os_str().is_empty())
305 .unwrap_or_else(|| Path::new("."));
306 fs::create_dir_all(parent).map_err(|error| {
307 storage_error(
308 format!("creating credential vault directory {}", parent.display()),
309 error,
310 )
311 })?;
312 let parent_metadata = fs::symlink_metadata(parent).map_err(|error| {
313 storage_error(
314 format!("inspecting credential vault directory {}", parent.display()),
315 error,
316 )
317 })?;
318 if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() {
319 return Err(Error::new(
320 ErrorKind::InvalidInput,
321 "credential vault parent must be a real directory",
322 ));
323 }
324 match fs::symlink_metadata(path) {
325 Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
326 return Err(Error::new(
327 ErrorKind::InvalidInput,
328 "credential vault destination must be a regular file",
329 ));
330 }
331 Ok(_) => {}
332 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
333 Err(error) => {
334 return Err(storage_error(
335 format!("inspecting credential vault {}", path.display()),
336 error,
337 ));
338 }
339 }
340
341 let temporary = parent.join(format!(".kcode-credential-vault-{}.tmp", Uuid::new_v4()));
342 let result = (|| -> Result<()> {
343 let mut options = OpenOptions::new();
344 options.write(true).create_new(true);
345 #[cfg(unix)]
346 {
347 use std::os::unix::fs::OpenOptionsExt;
348 options.mode(0o600);
349 }
350 let mut file = options.open(&temporary).map_err(|error| {
351 storage_error(
352 format!("creating temporary vault {}", temporary.display()),
353 error,
354 )
355 })?;
356 file.write_all(contents).map_err(|error| {
357 storage_error(
358 format!("writing temporary vault {}", temporary.display()),
359 error,
360 )
361 })?;
362 file.sync_all().map_err(|error| {
363 storage_error(
364 format!("synchronizing temporary vault {}", temporary.display()),
365 error,
366 )
367 })?;
368 drop(file);
369 fs::rename(&temporary, path).map_err(|error| {
370 storage_error(
371 format!("installing credential vault {}", path.display()),
372 error,
373 )
374 })?;
375 sync_parent(parent)?;
376 Ok(())
377 })();
378 if result.is_err() {
379 let _ = fs::remove_file(&temporary);
380 }
381 result
382}
383
384#[cfg(unix)]
385fn sync_parent(parent: &Path) -> Result<()> {
386 File::open(parent)
387 .and_then(|directory| directory.sync_all())
388 .map_err(|error| {
389 storage_error(
390 format!(
391 "synchronizing credential vault directory {}",
392 parent.display()
393 ),
394 error,
395 )
396 })
397}
398
399#[cfg(not(unix))]
400fn sync_parent(_parent: &Path) -> Result<()> {
401 Ok(())
402}
403
404fn storage_error(action: String, error: std::io::Error) -> Error {
405 Error::new(ErrorKind::Storage, format!("{action}: {error}"))
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 struct Fixture {
413 directory: std::path::PathBuf,
414 path: std::path::PathBuf,
415 }
416
417 impl Fixture {
418 fn new(label: &str) -> Self {
419 let directory = std::env::temp_dir()
420 .join(format!("kcode-credential-vault-{label}-{}", Uuid::new_v4()));
421 let path = directory.join("secrets.age");
422 Self { directory, path }
423 }
424 }
425
426 impl Drop for Fixture {
427 fn drop(&mut self) {
428 if self.directory.exists() {
429 fs::remove_dir_all(&self.directory).unwrap();
430 }
431 }
432 }
433
434 fn passphrase(value: &str) -> SecretString {
435 SecretString::from(value.to_owned())
436 }
437
438 fn write_encrypted_payload(fixture: &Fixture, plaintext: &[u8], password: &SecretString) {
439 fs::create_dir_all(&fixture.directory).unwrap();
440 fs::write(&fixture.path, encrypt(plaintext, password).unwrap()).unwrap();
441 }
442
443 #[test]
444 fn encrypted_vault_round_trips_without_plaintext_and_lists_sorted_names() {
445 let fixture = Fixture::new("round-trip");
446 let password = passphrase("correct horse battery staple");
447 let mut vault = CredentialVault::empty();
448 vault
449 .set("openai-api-key", "sk-test-private".into())
450 .unwrap();
451 vault
452 .set("telegram-bot-token", "123456:private".into())
453 .unwrap();
454 vault.save(&fixture.path, &password).unwrap();
455
456 let ciphertext = fs::read(&fixture.path).unwrap();
457 assert!(
458 !ciphertext
459 .windows(b"sk-test-private".len())
460 .any(|window| window == b"sk-test-private")
461 );
462 let restored = CredentialVault::unlock(&fixture.path, password).unwrap();
463 assert_eq!(
464 restored
465 .secret("openai-api-key")
466 .unwrap()
467 .unwrap()
468 .expose_secret(),
469 "sk-test-private"
470 );
471 assert_eq!(
472 restored.names().collect::<Vec<_>>(),
473 vec!["openai-api-key", "telegram-bot-token"]
474 );
475 }
476
477 #[test]
478 fn wrong_passphrase_is_a_sanitized_decryption_failure() {
479 let fixture = Fixture::new("wrong-passphrase");
480 CredentialVault::empty()
481 .save(&fixture.path, &passphrase("right passphrase"))
482 .unwrap();
483 let error =
484 CredentialVault::unlock(&fixture.path, passphrase("wrong passphrase")).unwrap_err();
485 assert_eq!(error.kind(), ErrorKind::Decryption);
486 assert!(error.to_string().contains("unlocking credential vault"));
487 assert!(!error.to_string().contains("wrong passphrase"));
488 }
489
490 #[test]
491 fn names_and_values_are_validated_at_the_public_boundary() {
492 let mut vault = CredentialVault::empty();
493 for invalid in [
494 "",
495 "contains/slash",
496 "contains space",
497 "snowman-☃",
498 &"a".repeat(MAX_SECRET_NAME_BYTES + 1),
499 ] {
500 let error = vault.set(invalid, "do-not-disclose".into()).unwrap_err();
501 assert_eq!(error.kind(), ErrorKind::InvalidInput);
502 assert!(!error.to_string().contains("do-not-disclose"));
503 assert!(vault.secret("valid-name").unwrap().is_none());
504 }
505 let error = vault.set("valid-name", String::new()).unwrap_err();
506 assert_eq!(error.kind(), ErrorKind::InvalidInput);
507 assert!(vault.names().next().is_none());
508 }
509
510 #[test]
511 fn replacement_and_removal_persist_without_exposing_values() {
512 let fixture = Fixture::new("replace-remove");
513 let password = passphrase("password");
514 let mut vault = CredentialVault::empty();
515 vault.set("service", "old-value".into()).unwrap();
516 vault.set("service", "new-value".into()).unwrap();
517 vault.set("remove-me", "gone".into()).unwrap();
518 assert!(vault.remove("remove-me").unwrap());
519 assert!(!vault.remove("remove-me").unwrap());
520 vault.save(&fixture.path, &password).unwrap();
521
522 let restored = CredentialVault::unlock(&fixture.path, password).unwrap();
523 assert_eq!(
524 restored.secret("service").unwrap().unwrap().expose_secret(),
525 "new-value"
526 );
527 assert!(restored.secret("remove-me").unwrap().is_none());
528 }
529
530 #[test]
531 fn corrupt_unsupported_and_invalid_decrypted_payloads_fail_closed() {
532 let password = passphrase("password");
533
534 let corrupt = Fixture::new("corrupt");
535 write_encrypted_payload(&corrupt, b"{", &password);
536 let error = CredentialVault::unlock(&corrupt.path, password.clone()).unwrap_err();
537 assert_eq!(error.kind(), ErrorKind::InvalidData);
538
539 let unsupported = Fixture::new("unsupported");
540 write_encrypted_payload(
541 &unsupported,
542 br#"{"version":2,"secrets":{"service":"private-value"}}"#,
543 &password,
544 );
545 let error = CredentialVault::unlock(&unsupported.path, password.clone()).unwrap_err();
546 assert_eq!(error.kind(), ErrorKind::UnsupportedVersion);
547 assert!(!format!("{error:?}").contains("private-value"));
548
549 let invalid = Fixture::new("invalid-entry");
550 write_encrypted_payload(
551 &invalid,
552 br#"{"version":1,"secrets":{"bad/name":"private-value"}}"#,
553 &password,
554 );
555 let error = CredentialVault::unlock(&invalid.path, password).unwrap_err();
556 assert_eq!(error.kind(), ErrorKind::InvalidData);
557 assert!(!format!("{error:?}").contains("private-value"));
558 }
559
560 #[test]
561 fn repeated_save_atomically_replaces_with_a_private_file_and_no_temp_artifact() {
562 let fixture = Fixture::new("atomic");
563 let password = passphrase("password");
564 let mut vault = CredentialVault::empty();
565 vault.set("service", "first".into()).unwrap();
566 vault.save(&fixture.path, &password).unwrap();
567 vault.set("service", "second".into()).unwrap();
568 vault.save(&fixture.path, &password).unwrap();
569
570 #[cfg(unix)]
571 {
572 use std::os::unix::fs::PermissionsExt;
573 assert_eq!(
574 fs::metadata(&fixture.path).unwrap().permissions().mode() & 0o777,
575 0o600
576 );
577 }
578 assert!(fs::read_dir(&fixture.directory).unwrap().all(|entry| {
579 !entry
580 .unwrap()
581 .file_name()
582 .to_string_lossy()
583 .starts_with(".kcode-credential-vault-")
584 }));
585 assert_eq!(
586 CredentialVault::unlock(&fixture.path, password)
587 .unwrap()
588 .secret("service")
589 .unwrap()
590 .unwrap()
591 .expose_secret(),
592 "second"
593 );
594 }
595
596 #[test]
597 fn debug_output_is_redacted() {
598 let mut vault = CredentialVault::empty();
599 vault.set("service", "private-value".into()).unwrap();
600 let debug = format!("{vault:?}");
601 assert!(debug.contains("secret_count"));
602 assert!(debug.contains("[REDACTED]"));
603 assert!(!debug.contains("service"));
604 assert!(!debug.contains("private-value"));
605 }
606
607 #[cfg(unix)]
608 #[test]
609 fn symlink_destination_is_rejected_without_touching_its_target() {
610 use std::os::unix::fs::symlink;
611
612 let fixture = Fixture::new("symlink");
613 fs::create_dir_all(&fixture.directory).unwrap();
614 let target = fixture.directory.join("target");
615 fs::write(&target, b"unchanged").unwrap();
616 symlink(&target, &fixture.path).unwrap();
617 let error = CredentialVault::empty()
618 .save(&fixture.path, &passphrase("password"))
619 .unwrap_err();
620 assert_eq!(error.kind(), ErrorKind::InvalidInput);
621 assert_eq!(fs::read(target).unwrap(), b"unchanged");
622 }
623}