use std::fs::OpenOptions;
use std::future::Future;
use std::io::Write;
use std::path::PathBuf;
use std::pin::Pin;
use tracing::warn;
use crate::error::AppError;
pub struct PlaintextSeedStore {
path: PathBuf,
}
impl PlaintextSeedStore {
pub fn new(data_dir: &std::path::Path) -> Self {
Self {
path: data_dir.join("seed.plaintext"),
}
}
}
impl super::SeedStore for PlaintextSeedStore {
fn get(&self) -> Pin<Box<dyn Future<Output = Result<Option<Vec<u8>>, AppError>> + Send + '_>> {
Box::pin(async {
warn!(
path = %self.path.display(),
"reading seed from PLAINTEXT file — this is NOT secure for production use"
);
match std::fs::read_to_string(&self.path) {
Ok(hex_seed) => {
let bytes = hex::decode(hex_seed.trim()).map_err(|e| {
AppError::SecretStore(format!(
"failed to decode hex seed from plaintext file: {e}"
))
})?;
Ok(Some(bytes))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(AppError::SecretStore(format!(
"failed to read plaintext seed file: {e}"
))),
}
})
}
fn set(&self, seed: &[u8]) -> Pin<Box<dyn Future<Output = Result<(), AppError>> + Send + '_>> {
let hex_seed = hex::encode(seed);
Box::pin(async move {
warn!(
path = %self.path.display(),
"writing seed to PLAINTEXT file — this is NOT secure for production use"
);
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
AppError::SecretStore(format!(
"failed to create directory for plaintext seed: {e}"
))
})?;
}
let mut opts = OpenOptions::new();
opts.create(true).write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(&self.path).map_err(|e| {
AppError::SecretStore(format!("failed to open plaintext seed file: {e}"))
})?;
file.write_all(hex_seed.as_bytes()).map_err(|e| {
AppError::SecretStore(format!("failed to write plaintext seed file: {e}"))
})?;
vta_cli_common::secure_file::restrict_file_to_owner(&self.path).map_err(|e| {
AppError::SecretStore(format!(
"failed to restrict plaintext seed file to owner: {e}"
))
})?;
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::super::SeedStore;
use super::*;
#[cfg(unix)]
#[tokio::test]
async fn set_writes_file_at_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store = PlaintextSeedStore::new(dir.path());
store.set(b"\x42".repeat(32).as_slice()).await.unwrap();
let mode = std::fs::metadata(&store.path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "plaintext seed file must be owner-only");
}
#[cfg(unix)]
#[tokio::test]
async fn set_overwrite_re_restricts_mode() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let store = PlaintextSeedStore::new(dir.path());
std::fs::write(&store.path, "stale").unwrap();
let mut perm = std::fs::metadata(&store.path).unwrap().permissions();
perm.set_mode(0o644);
std::fs::set_permissions(&store.path, perm).unwrap();
store.set(b"\x42".repeat(32).as_slice()).await.unwrap();
let mode = std::fs::metadata(&store.path).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"overwriting an existing seed file must re-apply 0600"
);
}
#[tokio::test]
async fn round_trip() {
let dir = tempfile::tempdir().unwrap();
let store = PlaintextSeedStore::new(dir.path());
let seed: Vec<u8> = (0..32).collect();
store.set(&seed).await.unwrap();
let got = store.get().await.unwrap().expect("seed present");
assert_eq!(got, seed);
}
}