Skip to main content

kcode_credential_vault/
lib.rs

1//! A small encrypted persistent store for named application credentials.
2
3use 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/// Stable high-level classification for a vault operation failure.
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21#[non_exhaustive]
22pub enum ErrorKind {
23    InvalidInput,
24    Decryption,
25    InvalidData,
26    UnsupportedVersion,
27    Storage,
28}
29
30/// A sanitized credential-vault failure.
31pub 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    /// Returns the stable high-level error category.
45    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
88/// An in-memory set of named credentials backed by one encrypted file.
89pub struct CredentialVault {
90    secrets: BTreeMap<String, String>,
91}
92
93impl CredentialVault {
94    /// Creates an empty in-memory vault without touching the filesystem.
95    pub fn empty() -> Self {
96        Self {
97            secrets: BTreeMap::new(),
98        }
99    }
100
101    /// Decrypts and validates one complete vault file.
102    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    /// Encrypts and durably replaces one complete vault file.
150    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    /// Inserts or replaces a named nonempty credential.
166    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    /// Removes a credential without exposing its value.
185    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    /// Returns a protected owned copy of one credential.
196    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    /// Iterates credential names in lexical order without exposing values.
202    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}