376 lines
13 KiB
Rust
376 lines
13 KiB
Rust
//! Central encrypted secret storage for provider/ACME credentials.
|
|
//! Plaintext exists only for the duration of an explicit read/write operation.
|
|
use anyhow::{ensure, Context, Result};
|
|
use rand::{rngs::OsRng, RngCore};
|
|
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
|
|
use serde::Serialize;
|
|
use sqlx::Row;
|
|
use std::{
|
|
fs::{self, OpenOptions},
|
|
io::{Read, Write},
|
|
path::{Path, PathBuf},
|
|
};
|
|
use zeroize::{Zeroize, Zeroizing};
|
|
|
|
const KEY_BYTES: usize = 32;
|
|
const NONCE_BYTES: usize = 12;
|
|
const ENCRYPTION_VERSION: i64 = 1;
|
|
const MAX_SECRET_BYTES: usize = 64 * 1024;
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
pub struct SecretMetadata {
|
|
pub secret_id: String,
|
|
pub provider: String,
|
|
pub encryption_version: i64,
|
|
pub metadata: serde_json::Value,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
}
|
|
|
|
fn secrets_dir(data_dir: &Path) -> PathBuf {
|
|
data_dir.join("secrets")
|
|
}
|
|
pub fn master_key_path(data_dir: &Path) -> PathBuf {
|
|
secrets_dir(data_dir).join("master.key")
|
|
}
|
|
|
|
fn private_directory(path: &Path) -> Result<()> {
|
|
if !path.exists() {
|
|
fs::create_dir_all(path)?;
|
|
}
|
|
let metadata = fs::symlink_metadata(path)?;
|
|
ensure!(
|
|
metadata.is_dir() && !metadata.file_type().is_symlink(),
|
|
"secret store directory must be a real directory"
|
|
);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if metadata.permissions().mode() & 0o077 != 0 {
|
|
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn read_key(path: &Path) -> Result<Zeroizing<[u8; KEY_BYTES]>> {
|
|
let metadata = fs::symlink_metadata(path).context("secret store master key is missing")?;
|
|
ensure!(
|
|
metadata.is_file() && !metadata.file_type().is_symlink(),
|
|
"secret store master key must be a regular file"
|
|
);
|
|
ensure!(metadata.len() == KEY_BYTES as u64, "invalid secret store master key size");
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
ensure!(
|
|
metadata.permissions().mode() & 0o077 == 0,
|
|
"secret store master key must have mode 0600"
|
|
);
|
|
}
|
|
let mut bytes = Zeroizing::new([0u8; KEY_BYTES]);
|
|
let mut options = OpenOptions::new();
|
|
options.read(true);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
options.custom_flags(libc::O_NOFOLLOW);
|
|
}
|
|
let mut file = options.open(path)?;
|
|
file.read_exact(bytes.as_mut())?;
|
|
let mut extra = [0u8; 1];
|
|
ensure!(file.read(&mut extra)? == 0, "invalid secret store master key size");
|
|
Ok(bytes)
|
|
}
|
|
|
|
pub fn initialize(data_dir: &Path) -> Result<()> {
|
|
let directory = secrets_dir(data_dir);
|
|
private_directory(&directory)?;
|
|
let path = master_key_path(data_dir);
|
|
if path.exists() {
|
|
let _ = read_key(&path)?;
|
|
return Ok(());
|
|
}
|
|
let mut key = Zeroizing::new([0u8; KEY_BYTES]);
|
|
OsRng.fill_bytes(key.as_mut());
|
|
let mut options = OpenOptions::new();
|
|
options.write(true).create_new(true);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
|
}
|
|
match options.open(&path) {
|
|
Ok(mut file) => {
|
|
file.write_all(key.as_ref())?;
|
|
file.sync_all()?;
|
|
}
|
|
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
let _ = read_key(&path)?;
|
|
}
|
|
Err(error) => return Err(error.into()),
|
|
}
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_identity(secret_id: &str, provider: &str) -> Result<()> {
|
|
ensure!(
|
|
!secret_id.is_empty()
|
|
&& secret_id.len() <= 256
|
|
&& secret_id
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'_' | b'-' | b'.')),
|
|
"invalid secret id"
|
|
);
|
|
ensure!(
|
|
!provider.is_empty()
|
|
&& provider.len() <= 64
|
|
&& provider
|
|
.bytes()
|
|
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')),
|
|
"invalid secret provider"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn aad(secret_id: &str, provider: &str) -> Vec<u8> {
|
|
format!("rustproxy-secret-v{ENCRYPTION_VERSION}\0{secret_id}\0{provider}").into_bytes()
|
|
}
|
|
|
|
fn key(data_dir: &Path) -> Result<LessSafeKey> {
|
|
let bytes = read_key(&master_key_path(data_dir))?;
|
|
let unbound = UnboundKey::new(&AES_256_GCM, bytes.as_ref())
|
|
.map_err(|_| anyhow::anyhow!("cannot initialize secret store cipher"))?;
|
|
Ok(LessSafeKey::new(unbound))
|
|
}
|
|
|
|
pub async fn put(
|
|
store: &crate::db::Store,
|
|
data_dir: &Path,
|
|
secret_id: &str,
|
|
provider: &str,
|
|
plaintext: &[u8],
|
|
metadata: serde_json::Value,
|
|
) -> Result<()> {
|
|
validate_identity(secret_id, provider)?;
|
|
ensure!(!plaintext.is_empty() && plaintext.len() <= MAX_SECRET_BYTES, "invalid secret size");
|
|
let metadata = serde_json::to_string(&metadata)?;
|
|
ensure!(metadata.len() <= 4096, "secret metadata is too large");
|
|
initialize(data_dir)?;
|
|
let cipher = key(data_dir)?;
|
|
let mut nonce = [0u8; NONCE_BYTES];
|
|
OsRng.fill_bytes(&mut nonce);
|
|
let mut ciphertext = Zeroizing::new(plaintext.to_vec());
|
|
cipher
|
|
.seal_in_place_append_tag(
|
|
Nonce::assume_unique_for_key(nonce),
|
|
Aad::from(aad(secret_id, provider)),
|
|
&mut *ciphertext,
|
|
)
|
|
.map_err(|_| anyhow::anyhow!("secret encryption failed"))?;
|
|
let now = crate::auth::now();
|
|
sqlx::query(
|
|
"INSERT INTO secret_store(secret_id, provider, encryption_version, nonce, ciphertext, metadata, created_at, updated_at) \
|
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?) \
|
|
ON CONFLICT(secret_id) DO UPDATE SET provider=excluded.provider, encryption_version=excluded.encryption_version, \
|
|
nonce=excluded.nonce, ciphertext=excluded.ciphertext, metadata=excluded.metadata, updated_at=excluded.updated_at",
|
|
)
|
|
.bind(secret_id)
|
|
.bind(provider)
|
|
.bind(ENCRYPTION_VERSION)
|
|
.bind(nonce.to_vec())
|
|
.bind(ciphertext.as_slice())
|
|
.bind(metadata)
|
|
.bind(now)
|
|
.bind(now)
|
|
.execute(&store.pool)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get(
|
|
store: &crate::db::Store,
|
|
data_dir: &Path,
|
|
secret_id: &str,
|
|
expected_provider: Option<&str>,
|
|
) -> Result<Zeroizing<Vec<u8>>> {
|
|
let row = sqlx::query(
|
|
"SELECT provider, encryption_version, nonce, ciphertext FROM secret_store WHERE secret_id = ?",
|
|
)
|
|
.bind(secret_id)
|
|
.fetch_optional(&store.pool)
|
|
.await?
|
|
.ok_or_else(|| anyhow::anyhow!("secret not found"))?;
|
|
let provider: String = row.try_get("provider")?;
|
|
if let Some(expected) = expected_provider {
|
|
ensure!(provider == expected, "secret provider mismatch");
|
|
}
|
|
validate_identity(secret_id, &provider)?;
|
|
let version: i64 = row.try_get("encryption_version")?;
|
|
ensure!(version == ENCRYPTION_VERSION, "unsupported secret encryption version");
|
|
let nonce: Vec<u8> = row.try_get("nonce")?;
|
|
ensure!(nonce.len() == NONCE_BYTES, "invalid secret nonce");
|
|
let mut ciphertext = Zeroizing::new(row.try_get::<Vec<u8>, _>("ciphertext")?);
|
|
ensure!(
|
|
ciphertext.len() >= AES_256_GCM.tag_len() && ciphertext.len() <= MAX_SECRET_BYTES + AES_256_GCM.tag_len(),
|
|
"invalid encrypted secret size"
|
|
);
|
|
let mut nonce_bytes = [0u8; NONCE_BYTES];
|
|
nonce_bytes.copy_from_slice(&nonce);
|
|
let cipher = key(data_dir)?;
|
|
let plaintext = cipher
|
|
.open_in_place(
|
|
Nonce::assume_unique_for_key(nonce_bytes),
|
|
Aad::from(aad(secret_id, &provider)),
|
|
ciphertext.as_mut(),
|
|
)
|
|
.map_err(|_| anyhow::anyhow!("secret authentication failed"))?;
|
|
let output = Zeroizing::new(plaintext.to_vec());
|
|
ciphertext.zeroize();
|
|
Ok(output)
|
|
}
|
|
|
|
pub async fn delete(store: &crate::db::Store, secret_id: &str) -> Result<bool> {
|
|
let result = sqlx::query("DELETE FROM secret_store WHERE secret_id = ?")
|
|
.bind(secret_id)
|
|
.execute(&store.pool)
|
|
.await?;
|
|
Ok(result.rows_affected() > 0)
|
|
}
|
|
|
|
pub async fn metadata(store: &crate::db::Store) -> Result<Vec<SecretMetadata>> {
|
|
let rows = sqlx::query(
|
|
"SELECT secret_id, provider, encryption_version, metadata, created_at, updated_at FROM secret_store ORDER BY secret_id",
|
|
)
|
|
.fetch_all(&store.pool)
|
|
.await?;
|
|
rows.into_iter()
|
|
.map(|row| {
|
|
let raw: String = row.try_get("metadata")?;
|
|
Ok(SecretMetadata {
|
|
secret_id: row.try_get("secret_id")?,
|
|
provider: row.try_get("provider")?,
|
|
encryption_version: row.try_get("encryption_version")?,
|
|
metadata: serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null),
|
|
created_at: row.try_get("created_at")?,
|
|
updated_at: row.try_get("updated_at")?,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
pub async fn validate_all(store: &crate::db::Store, data_dir: &Path) -> Result<()> {
|
|
for item in metadata(store).await? {
|
|
let value = get(store, data_dir, &item.secret_id, Some(&item.provider)).await?;
|
|
ensure!(!value.is_empty(), "empty secret store record");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn acme_secret_id(certificate_id: &str, provider: &str) -> String {
|
|
format!("acme/{certificate_id}/{provider}/credential")
|
|
}
|
|
|
|
pub async fn migrate_acme_credentials(
|
|
store: &crate::db::Store,
|
|
data_dir: &Path,
|
|
cert_dir: &Path,
|
|
) -> Result<usize> {
|
|
initialize(data_dir)?;
|
|
let root = cert_dir.join("acme");
|
|
if !root.exists() {
|
|
return Ok(0);
|
|
}
|
|
let root_meta = fs::symlink_metadata(&root)?;
|
|
ensure!(root_meta.is_dir() && !root_meta.file_type().is_symlink(), "invalid ACME directory");
|
|
let mut migrated = 0usize;
|
|
for entry in fs::read_dir(&root)? {
|
|
let entry = entry?;
|
|
let profile_dir = entry.path();
|
|
let meta = fs::symlink_metadata(&profile_dir)?;
|
|
if !meta.is_dir() || meta.file_type().is_symlink() {
|
|
continue;
|
|
}
|
|
let profile_path = profile_dir.join("profile.json");
|
|
if !profile_path.is_file() {
|
|
continue;
|
|
}
|
|
let profile: serde_json::Value = serde_json::from_slice(&fs::read(&profile_path)?)?;
|
|
let provider = profile.get("provider").and_then(serde_json::Value::as_str).unwrap_or("");
|
|
let certificate_id = profile
|
|
.get("certificate_id")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("");
|
|
if provider == "http01" {
|
|
continue;
|
|
}
|
|
validate_identity(&acme_secret_id(certificate_id, provider), provider)?;
|
|
let credential_path = profile_dir.join(format!("{provider}.ini"));
|
|
if !credential_path.exists() {
|
|
continue;
|
|
}
|
|
let credential_meta = fs::symlink_metadata(&credential_path)?;
|
|
ensure!(
|
|
credential_meta.is_file()
|
|
&& !credential_meta.file_type().is_symlink()
|
|
&& credential_meta.len() <= MAX_SECRET_BYTES as u64,
|
|
"unsafe legacy ACME credential file"
|
|
);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
ensure!(
|
|
credential_meta.permissions().mode() & 0o077 == 0,
|
|
"legacy ACME credential file must be private"
|
|
);
|
|
}
|
|
let mut plaintext = Zeroizing::new(fs::read(&credential_path)?);
|
|
ensure!(!plaintext.is_empty(), "empty legacy ACME credential file");
|
|
put(
|
|
store,
|
|
data_dir,
|
|
&acme_secret_id(certificate_id, provider),
|
|
provider,
|
|
plaintext.as_ref(),
|
|
serde_json::json!({"source":"acme_credential_migration","certificate_id":certificate_id}),
|
|
)
|
|
.await?;
|
|
plaintext.zeroize();
|
|
fs::remove_file(&credential_path).context("legacy ACME credential was encrypted but could not be removed")?;
|
|
migrated += 1;
|
|
}
|
|
Ok(migrated)
|
|
}
|
|
|
|
pub fn read_master_key_for_backup(data_dir: &Path) -> Result<Zeroizing<[u8; KEY_BYTES]>> {
|
|
initialize(data_dir)?;
|
|
read_key(&master_key_path(data_dir))
|
|
}
|
|
|
|
pub fn write_restored_master_key(data_dir: &Path, key: &[u8]) -> Result<()> {
|
|
ensure!(key.len() == KEY_BYTES, "invalid restored secret store key");
|
|
let directory = secrets_dir(data_dir);
|
|
private_directory(&directory)?;
|
|
let path = master_key_path(data_dir);
|
|
ensure!(!path.exists(), "restored secret store key already exists");
|
|
let mut options = OpenOptions::new();
|
|
options.write(true).create_new(true);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
|
|
}
|
|
let mut file = options.open(&path)?;
|
|
file.write_all(key)?;
|
|
file.sync_all()?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
include!("secret_store/tests.rs");
|