v0.24.9
This commit is contained in:
1 parent
1297ea2920
commit
dc0fcabf51
49 files changed
+2632
-252
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user