use std::{
collections::BTreeMap,
error, fmt,
fs::{self, File, OpenOptions},
io::{Read, Write},
path::Path,
};
pub use age::secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use zeroize::{Zeroize, Zeroizing};
const VAULT_VERSION: u32 = 1;
const MAX_SECRET_NAME_BYTES: usize = 128;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum ErrorKind {
InvalidInput,
Decryption,
InvalidData,
UnsupportedVersion,
Storage,
}
pub struct Error {
kind: ErrorKind,
message: String,
}
impl Error {
fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
pub fn kind(&self) -> ErrorKind {
self.kind
}
}
impl fmt::Debug for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Error")
.field("kind", &self.kind)
.field("message", &self.message)
.finish()
}
}
impl fmt::Display for Error {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
impl error::Error for Error {}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Deserialize)]
struct VaultPayload {
version: u32,
secrets: BTreeMap<String, String>,
}
impl Drop for VaultPayload {
fn drop(&mut self) {
zeroize_values(&mut self.secrets);
}
}
#[derive(Serialize)]
struct VaultPayloadRef<'a> {
version: u32,
secrets: &'a BTreeMap<String, String>,
}
pub struct CredentialVault {
secrets: BTreeMap<String, String>,
}
impl CredentialVault {
pub fn empty() -> Self {
Self {
secrets: BTreeMap::new(),
}
}
pub fn unlock(path: &Path, passphrase: SecretString) -> Result<Self> {
let ciphertext = fs::read(path).map_err(|error| {
storage_error(
format!("reading credential vault {}", path.display()),
error,
)
})?;
let decryptor = age::Decryptor::new(&ciphertext[..]).map_err(|_| {
Error::new(
ErrorKind::Decryption,
"reading encrypted credential vault failed",
)
})?;
let identity = age::scrypt::Identity::new(passphrase);
let mut reader = decryptor
.decrypt(std::iter::once(&identity as &dyn age::Identity))
.map_err(|_| {
Error::new(
ErrorKind::Decryption,
"unlocking credential vault failed; the passphrase may be incorrect",
)
})?;
let mut plaintext = Zeroizing::new(Vec::new());
reader
.read_to_end(&mut plaintext)
.map_err(|_| Error::new(ErrorKind::Decryption, "decrypting credential vault failed"))?;
let mut payload: VaultPayload = serde_json::from_slice(&plaintext).map_err(|error| {
Error::new(
ErrorKind::InvalidData,
format!("parsing decrypted credential vault failed: {error}"),
)
})?;
if payload.version != VAULT_VERSION {
return Err(Error::new(
ErrorKind::UnsupportedVersion,
format!(
"credential vault version {} is unsupported",
payload.version
),
));
}
validate_stored_secrets(&payload.secrets)?;
Ok(Self {
secrets: std::mem::take(&mut payload.secrets),
})
}
pub fn save(&self, path: &Path, passphrase: &SecretString) -> Result<()> {
let payload = VaultPayloadRef {
version: VAULT_VERSION,
secrets: &self.secrets,
};
let plaintext = Zeroizing::new(serde_json::to_vec(&payload).map_err(|error| {
Error::new(
ErrorKind::InvalidData,
format!("serializing credential vault failed: {error}"),
)
})?);
let ciphertext = encrypt(&plaintext, passphrase)?;
write_private_atomic(path, &ciphertext)
}
pub fn set(&mut self, name: &str, mut value: String) -> Result<()> {
if let Err(error) = validate_secret_name(name) {
value.zeroize();
return Err(error);
}
if value.is_empty() {
value.zeroize();
return Err(Error::new(
ErrorKind::InvalidInput,
"secret values cannot be empty",
));
}
if let Some(mut previous) = self.secrets.insert(name.to_owned(), value) {
previous.zeroize();
}
Ok(())
}
pub fn remove(&mut self, name: &str) -> Result<bool> {
validate_secret_name(name)?;
if let Some(mut value) = self.secrets.remove(name) {
value.zeroize();
Ok(true)
} else {
Ok(false)
}
}
pub fn secret(&self, name: &str) -> Result<Option<SecretString>> {
validate_secret_name(name)?;
Ok(self.secrets.get(name).cloned().map(SecretString::from))
}
pub fn names(&self) -> impl Iterator<Item = &str> {
self.secrets.keys().map(String::as_str)
}
}
impl Default for CredentialVault {
fn default() -> Self {
Self::empty()
}
}
impl fmt::Debug for CredentialVault {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("CredentialVault")
.field("secret_count", &self.secrets.len())
.field("secrets", &"[REDACTED]")
.finish()
}
}
impl Drop for CredentialVault {
fn drop(&mut self) {
zeroize_values(&mut self.secrets);
}
}
fn zeroize_values(values: &mut BTreeMap<String, String>) {
for value in values.values_mut() {
value.zeroize();
}
}
fn valid_secret_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= MAX_SECRET_NAME_BYTES
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}
fn validate_secret_name(name: &str) -> Result<()> {
if !valid_secret_name(name) {
return Err(Error::new(
ErrorKind::InvalidInput,
"secret names must contain 1-128 ASCII letters, numbers, dots, dashes, or underscores",
));
}
Ok(())
}
fn validate_stored_secrets(secrets: &BTreeMap<String, String>) -> Result<()> {
if secrets
.iter()
.any(|(name, value)| !valid_secret_name(name) || value.is_empty())
{
return Err(Error::new(
ErrorKind::InvalidData,
"the decrypted credential vault contains an invalid secret entry",
));
}
Ok(())
}
fn encrypt(plaintext: &[u8], passphrase: &SecretString) -> Result<Vec<u8>> {
let encryptor = age::Encryptor::with_user_passphrase(passphrase.clone());
let mut ciphertext = Vec::new();
{
let mut writer = encryptor.wrap_output(&mut ciphertext).map_err(|_| {
Error::new(
ErrorKind::InvalidData,
"starting credential vault encryption failed",
)
})?;
writer.write_all(plaintext).map_err(|_| {
Error::new(ErrorKind::InvalidData, "encrypting credential vault failed")
})?;
writer.finish().map_err(|_| {
Error::new(
ErrorKind::InvalidData,
"finishing credential vault encryption failed",
)
})?;
}
Ok(ciphertext)
}
fn write_private_atomic(path: &Path, contents: &[u8]) -> Result<()> {
let file_name = path.file_name().ok_or_else(|| {
Error::new(
ErrorKind::InvalidInput,
"credential vault path must name a file",
)
})?;
if file_name.is_empty() {
return Err(Error::new(
ErrorKind::InvalidInput,
"credential vault path must name a file",
));
}
let parent = path
.parent()
.filter(|value| !value.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent).map_err(|error| {
storage_error(
format!("creating credential vault directory {}", parent.display()),
error,
)
})?;
let parent_metadata = fs::symlink_metadata(parent).map_err(|error| {
storage_error(
format!("inspecting credential vault directory {}", parent.display()),
error,
)
})?;
if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() {
return Err(Error::new(
ErrorKind::InvalidInput,
"credential vault parent must be a real directory",
));
}
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err(Error::new(
ErrorKind::InvalidInput,
"credential vault destination must be a regular file",
));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(storage_error(
format!("inspecting credential vault {}", path.display()),
error,
));
}
}
let temporary = parent.join(format!(".kcode-credential-vault-{}.tmp", Uuid::new_v4()));
let result = (|| -> Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&temporary).map_err(|error| {
storage_error(
format!("creating temporary vault {}", temporary.display()),
error,
)
})?;
file.write_all(contents).map_err(|error| {
storage_error(
format!("writing temporary vault {}", temporary.display()),
error,
)
})?;
file.sync_all().map_err(|error| {
storage_error(
format!("synchronizing temporary vault {}", temporary.display()),
error,
)
})?;
drop(file);
fs::rename(&temporary, path).map_err(|error| {
storage_error(
format!("installing credential vault {}", path.display()),
error,
)
})?;
sync_parent(parent)?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
#[cfg(unix)]
fn sync_parent(parent: &Path) -> Result<()> {
File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| {
storage_error(
format!(
"synchronizing credential vault directory {}",
parent.display()
),
error,
)
})
}
#[cfg(not(unix))]
fn sync_parent(_parent: &Path) -> Result<()> {
Ok(())
}
fn storage_error(action: String, error: std::io::Error) -> Error {
Error::new(ErrorKind::Storage, format!("{action}: {error}"))
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixture {
directory: std::path::PathBuf,
path: std::path::PathBuf,
}
impl Fixture {
fn new(label: &str) -> Self {
let directory = std::env::temp_dir()
.join(format!("kcode-credential-vault-{label}-{}", Uuid::new_v4()));
let path = directory.join("secrets.age");
Self { directory, path }
}
}
impl Drop for Fixture {
fn drop(&mut self) {
if self.directory.exists() {
fs::remove_dir_all(&self.directory).unwrap();
}
}
}
fn passphrase(value: &str) -> SecretString {
SecretString::from(value.to_owned())
}
fn write_encrypted_payload(fixture: &Fixture, plaintext: &[u8], password: &SecretString) {
fs::create_dir_all(&fixture.directory).unwrap();
fs::write(&fixture.path, encrypt(plaintext, password).unwrap()).unwrap();
}
#[test]
fn encrypted_vault_round_trips_without_plaintext_and_lists_sorted_names() {
let fixture = Fixture::new("round-trip");
let password = passphrase("correct horse battery staple");
let mut vault = CredentialVault::empty();
vault
.set("openai-api-key", "sk-test-private".into())
.unwrap();
vault
.set("telegram-bot-token", "123456:private".into())
.unwrap();
vault.save(&fixture.path, &password).unwrap();
let ciphertext = fs::read(&fixture.path).unwrap();
assert!(
!ciphertext
.windows(b"sk-test-private".len())
.any(|window| window == b"sk-test-private")
);
let restored = CredentialVault::unlock(&fixture.path, password).unwrap();
assert_eq!(
restored
.secret("openai-api-key")
.unwrap()
.unwrap()
.expose_secret(),
"sk-test-private"
);
assert_eq!(
restored.names().collect::<Vec<_>>(),
vec!["openai-api-key", "telegram-bot-token"]
);
}
#[test]
fn wrong_passphrase_is_a_sanitized_decryption_failure() {
let fixture = Fixture::new("wrong-passphrase");
CredentialVault::empty()
.save(&fixture.path, &passphrase("right passphrase"))
.unwrap();
let error =
CredentialVault::unlock(&fixture.path, passphrase("wrong passphrase")).unwrap_err();
assert_eq!(error.kind(), ErrorKind::Decryption);
assert!(error.to_string().contains("unlocking credential vault"));
assert!(!error.to_string().contains("wrong passphrase"));
}
#[test]
fn names_and_values_are_validated_at_the_public_boundary() {
let mut vault = CredentialVault::empty();
for invalid in [
"",
"contains/slash",
"contains space",
"snowman-☃",
&"a".repeat(MAX_SECRET_NAME_BYTES + 1),
] {
let error = vault.set(invalid, "do-not-disclose".into()).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidInput);
assert!(!error.to_string().contains("do-not-disclose"));
assert!(vault.secret("valid-name").unwrap().is_none());
}
let error = vault.set("valid-name", String::new()).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidInput);
assert!(vault.names().next().is_none());
}
#[test]
fn replacement_and_removal_persist_without_exposing_values() {
let fixture = Fixture::new("replace-remove");
let password = passphrase("password");
let mut vault = CredentialVault::empty();
vault.set("service", "old-value".into()).unwrap();
vault.set("service", "new-value".into()).unwrap();
vault.set("remove-me", "gone".into()).unwrap();
assert!(vault.remove("remove-me").unwrap());
assert!(!vault.remove("remove-me").unwrap());
vault.save(&fixture.path, &password).unwrap();
let restored = CredentialVault::unlock(&fixture.path, password).unwrap();
assert_eq!(
restored.secret("service").unwrap().unwrap().expose_secret(),
"new-value"
);
assert!(restored.secret("remove-me").unwrap().is_none());
}
#[test]
fn corrupt_unsupported_and_invalid_decrypted_payloads_fail_closed() {
let password = passphrase("password");
let corrupt = Fixture::new("corrupt");
write_encrypted_payload(&corrupt, b"{", &password);
let error = CredentialVault::unlock(&corrupt.path, password.clone()).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidData);
let unsupported = Fixture::new("unsupported");
write_encrypted_payload(
&unsupported,
br#"{"version":2,"secrets":{"service":"private-value"}}"#,
&password,
);
let error = CredentialVault::unlock(&unsupported.path, password.clone()).unwrap_err();
assert_eq!(error.kind(), ErrorKind::UnsupportedVersion);
assert!(!format!("{error:?}").contains("private-value"));
let invalid = Fixture::new("invalid-entry");
write_encrypted_payload(
&invalid,
br#"{"version":1,"secrets":{"bad/name":"private-value"}}"#,
&password,
);
let error = CredentialVault::unlock(&invalid.path, password).unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidData);
assert!(!format!("{error:?}").contains("private-value"));
}
#[test]
fn repeated_save_atomically_replaces_with_a_private_file_and_no_temp_artifact() {
let fixture = Fixture::new("atomic");
let password = passphrase("password");
let mut vault = CredentialVault::empty();
vault.set("service", "first".into()).unwrap();
vault.save(&fixture.path, &password).unwrap();
vault.set("service", "second".into()).unwrap();
vault.save(&fixture.path, &password).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&fixture.path).unwrap().permissions().mode() & 0o777,
0o600
);
}
assert!(fs::read_dir(&fixture.directory).unwrap().all(|entry| {
!entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".kcode-credential-vault-")
}));
assert_eq!(
CredentialVault::unlock(&fixture.path, password)
.unwrap()
.secret("service")
.unwrap()
.unwrap()
.expose_secret(),
"second"
);
}
#[test]
fn debug_output_is_redacted() {
let mut vault = CredentialVault::empty();
vault.set("service", "private-value".into()).unwrap();
let debug = format!("{vault:?}");
assert!(debug.contains("secret_count"));
assert!(debug.contains("[REDACTED]"));
assert!(!debug.contains("service"));
assert!(!debug.contains("private-value"));
}
#[cfg(unix)]
#[test]
fn symlink_destination_is_rejected_without_touching_its_target() {
use std::os::unix::fs::symlink;
let fixture = Fixture::new("symlink");
fs::create_dir_all(&fixture.directory).unwrap();
let target = fixture.directory.join("target");
fs::write(&target, b"unchanged").unwrap();
symlink(&target, &fixture.path).unwrap();
let error = CredentialVault::empty()
.save(&fixture.path, &passphrase("password"))
.unwrap_err();
assert_eq!(error.kind(), ErrorKind::InvalidInput);
assert_eq!(fs::read(target).unwrap(), b"unchanged");
}
}