1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
use std::fs::OpenOptions;
use std::future::Future;
use std::io::Write;
use std::path::PathBuf;
use std::pin::Pin;
use tracing::warn;
use vti_common::error::AppError;
/// Plaintext file-based seed store (NOT secure — use only for development).
///
/// The seed is stored as a hex-encoded string in a plaintext file.
/// A warning is emitted on every access. Even though this backend is
/// dev-only, the file is still written with owner-only permissions
/// (`0600` on Unix; user-only DACL on Windows) so a misconfigured dev
/// VM can't trivially leak the BIP-32 master seed to other local users.
pub struct PlaintextSeedStore {
path: PathBuf,
}
impl PlaintextSeedStore {
/// Store at `<data_dir>/seed.plaintext` (the VTA default).
pub fn new(data_dir: &std::path::Path) -> Self {
Self::with_filename(data_dir, "seed.plaintext")
}
/// Store at `<data_dir>/<filename>`. Lets a consumer pin a
/// backend-specific filename (e.g. the VTC uses `secret.plaintext`)
/// while sharing this implementation.
pub fn with_filename(data_dir: &std::path::Path, filename: &str) -> Self {
Self {
path: data_dir.join(filename),
}
}
}
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}"
))
})?;
}
// Open with restrictive permissions BEFORE writing so the seed
// never lands at the default umask (0644 on most distros) even
// for the brief window between create and chmod. On Unix,
// `mode(0o600)` on `OpenOptions` is honoured at file-creation
// time; on Windows we apply the user-only DACL after write.
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}"))
})?;
// Belt-and-braces: re-assert the owner-only restriction in
// case the file already existed before this open() — a
// pre-existing file at 0644 would have kept its mode through
// a plain `OpenOptions::open` even with `mode(0o600)` set
// (the mode hint only applies on creation). On Windows, this
// is the primary mechanism (no `OpenOptions::mode` analogue).
vti_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::*;
/// Pin the owner-only file-mode invariant on Unix. A misconfigured
/// dev VM with the plaintext backend (banner notwithstanding) is
/// one operator-typo away from leaking the BIP-32 master seed to
/// every local user — `0600` is the cheapest defence.
#[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");
}
/// Re-writing an existing file must keep the restrictive mode even
/// if the existing file had a permissive mode (the
/// belt-and-braces post-write `restrict_file_to_owner` is what
/// guarantees this — `OpenOptions::mode` only applies at creation).
#[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());
// Pre-create the file at 0644 to simulate a plaintext seed
// file that was originally written by an older buggy version.
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"
);
}
/// Round-trip sanity — the security fix didn't break get/set.
#[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);
}
}