This commit is contained in:
Mateusz Gruszczyński committed 2026-10-03 18:10:20 +02:00
1 parent 1297ea2920
commit dc0fcabf51
49 files changed
+2632 -252

No files matched your search

+435
View File
@@ -0,0 +1,435 @@
//! Central encrypted secret storage for provider/ACME credentials.
//! Plaintext exists only for the duration of an explicit read/write operation.
use anyhow::{bail, 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)),
ciphertext.as_mut(),
)
.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)]
mod tests {
use super::*;
#[tokio::test]
async fn secret_round_trip_is_encrypted_in_sqlite_and_survives_key_reload() {
let dir = tempfile::tempdir().unwrap();
crate::auth::prepare_directory(dir.path()).unwrap();
let database = dir.path().join("config.sqlite3");
crate::auth::prepare_database(&database).unwrap();
let store = crate::db::Store::open(&database, &crate::config::Config::default())
.await
.unwrap();
initialize(dir.path()).unwrap();
let secret = b"cloudflare-example-token-0123456789";
put(
&store,
dir.path(),
"provider/cloudflare/demo/api_token",
"cloudflare",
secret,
serde_json::json!({"id":"demo"}),
)
.await
.unwrap();
let row: Vec<u8> = sqlx::query_scalar(
"SELECT ciphertext FROM secret_store WHERE secret_id='provider/cloudflare/demo/api_token'",
)
.fetch_one(&store.pool)
.await
.unwrap();
assert!(!row.windows(secret.len()).any(|window| window == secret));
let value = get(
&store,
dir.path(),
"provider/cloudflare/demo/api_token",
Some("cloudflare"),
)
.await
.unwrap();
assert_eq!(value.as_slice(), secret);
drop(value);
assert_eq!(read_key(&master_key_path(dir.path())).unwrap().len(), KEY_BYTES);
}
#[tokio::test]
async fn tampered_ciphertext_is_rejected() {
let dir = tempfile::tempdir().unwrap();
crate::auth::prepare_directory(dir.path()).unwrap();
let database = dir.path().join("config.sqlite3");
crate::auth::prepare_database(&database).unwrap();
let store = crate::db::Store::open(&database, &crate::config::Config::default())
.await
.unwrap();
put(&store, dir.path(), "acme/site/cloudflare/credential", "cloudflare", b"secret-value", serde_json::json!({})).await.unwrap();
sqlx::query("UPDATE secret_store SET ciphertext = zeroblob(length(ciphertext))")
.execute(&store.pool)
.await
.unwrap();
assert!(get(&store, dir.path(), "acme/site/cloudflare/credential", Some("cloudflare")).await.is_err());
}
}