Skip to main content

fedimint_server/config/
io.rs

1use std::fmt::Display;
2use std::fs;
3use std::fs::OpenOptions;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use anyhow::ensure;
8use fedimint_aead::{LessSafeKey, encrypted_read, encrypted_write, get_encryption_key};
9use fedimint_core::module::ApiAuth;
10use fedimint_core::util::write_new;
11use fedimint_logging::LOG_CORE;
12use fedimint_server_core::ServerModuleInitRegistry;
13use serde::Serialize;
14use serde::de::DeserializeOwned;
15use tracing::{debug, info, warn};
16
17use crate::config::{ServerConfig, ServerConfigPrivate};
18
19/// Client configuration file
20pub const CLIENT_CONFIG: &str = "client";
21
22/// Server encrypted private keys file
23pub const PRIVATE_CONFIG: &str = "private";
24
25/// Server locally configurable file
26pub const LOCAL_CONFIG: &str = "local";
27
28/// Server consensus-only configurable file
29pub const CONSENSUS_CONFIG: &str = "consensus";
30
31/// Client connection string file
32pub const CLIENT_INVITE_CODE_FILE: &str = "invite-code";
33
34/// Salt backup for combining with the private key
35pub const SALT_FILE: &str = "private.salt";
36
37/// Plain-text stored password, used to restart the server without having to
38/// send a password in via the API
39pub const PLAINTEXT_PASSWORD: &str = "password.private";
40
41/// Database file name
42pub const DB_FILE: &str = "database";
43
44pub const JSON_EXT: &str = "json";
45
46pub const ENCRYPTED_EXT: &str = "encrypt";
47
48pub const NEW_VERSION_FILE_EXT: &str = "new";
49
50/// Reads the server from the local, private, and consensus cfg files
51pub fn read_server_config(password: &str, path: &Path) -> anyhow::Result<ServerConfig> {
52    let salt = fs::read_to_string(path.join(SALT_FILE))?;
53    let key = get_encryption_key(password, &salt)?;
54
55    Ok(ServerConfig {
56        consensus: plaintext_json_read(&path.join(CONSENSUS_CONFIG))?,
57        local: plaintext_json_read(&path.join(LOCAL_CONFIG))?,
58        private: encrypted_json_read(&key, &path.join(PRIVATE_CONFIG))?,
59    })
60}
61
62/// Reads a plaintext json file into a struct
63fn plaintext_json_read<T: Serialize + DeserializeOwned>(path: &Path) -> anyhow::Result<T> {
64    let string = fs::read_to_string(path.with_extension(JSON_EXT))?;
65    Ok(serde_json::from_str(&string)?)
66}
67
68/// Reads an encrypted json file into a struct
69fn encrypted_json_read<T: Serialize + DeserializeOwned>(
70    key: &LessSafeKey,
71    path: &Path,
72) -> anyhow::Result<T> {
73    let decrypted = encrypted_read(key, path.with_extension(ENCRYPTED_EXT));
74    let string = String::from_utf8(decrypted?)?;
75    Ok(serde_json::from_str(&string)?)
76}
77
78/// Writes the server into configuration files (private keys encrypted)
79pub fn write_server_config(
80    server: &ServerConfig,
81    path: &Path,
82    password: &str,
83    module_config_gens: &ServerModuleInitRegistry,
84    api_secret: Option<String>,
85) -> anyhow::Result<()> {
86    let salt = fs::read_to_string(path.join(SALT_FILE))?;
87    let key = get_encryption_key(password, &salt)?;
88
89    let client_config = server.consensus.to_client_config(module_config_gens)?;
90    plaintext_json_write(&server.local, &path.join(LOCAL_CONFIG))?;
91    plaintext_json_write(&server.consensus, &path.join(CONSENSUS_CONFIG))?;
92    plaintext_display_write(
93        &server.get_invite_code(api_secret),
94        &path.join(CLIENT_INVITE_CODE_FILE),
95    )?;
96    plaintext_json_write(&client_config, &path.join(CLIENT_CONFIG))?;
97    encrypted_json_write(&server.private, &key, &path.join(PRIVATE_CONFIG))
98}
99
100/// Writes struct into a plaintext json file
101fn plaintext_json_write<T: Serialize + DeserializeOwned>(
102    obj: &T,
103    path: &Path,
104) -> anyhow::Result<()> {
105    let file = fs::File::options()
106        .create_new(true)
107        .write(true)
108        .open(path.with_extension(JSON_EXT))?;
109
110    serde_json::to_writer_pretty(file, obj)?;
111    Ok(())
112}
113
114fn plaintext_display_write<T: Display>(obj: &T, path: &Path) -> anyhow::Result<()> {
115    let mut file = fs::File::options()
116        .create_new(true)
117        .write(true)
118        .open(path)?;
119    file.write_all(obj.to_string().as_bytes())?;
120    Ok(())
121}
122
123/// Writes struct into an encrypted json file
124pub fn encrypted_json_write<T: Serialize + DeserializeOwned>(
125    obj: &T,
126    key: &LessSafeKey,
127    path: &Path,
128) -> anyhow::Result<()> {
129    let bytes = serde_json::to_string(obj)?.into_bytes();
130    encrypted_write(bytes, key, path.with_extension(ENCRYPTED_EXT))
131}
132
133/// We definitely don't want leading/trailing newlines in passwords, and a user
134/// editing the file manually will probably get a free newline added
135/// by the text editor.
136pub fn trim_password(password: &str) -> &str {
137    let password_fully_trimmed = password.trim();
138    if password_fully_trimmed != password {
139        warn!(
140            target: LOG_CORE,
141            "Password in the password file contains leading/trailing whitespaces. This will an error in the future."
142        );
143    }
144    password_fully_trimmed
145}
146
147pub fn backup_copy_path(original: &Path) -> PathBuf {
148    original.with_extension("bak")
149}
150
151pub fn create_backup_copy(original: &Path) -> anyhow::Result<()> {
152    let backup_path = backup_copy_path(original);
153    info!(target: LOG_CORE, ?original, ?backup_path, "Creating backup copy of file");
154    ensure!(
155        !backup_path.exists(),
156        "Already have a backup at {backup_path:?}, would be overwritten"
157    );
158    fs::copy(original, backup_path)?;
159    Ok(())
160}
161
162/// Re-encrypts the private config with a new password.
163///
164/// Note that we assume that the in-memory secret config equals the on-disk
165/// secret config. If the process is interrupted,
166/// [`recover_interrupted_password_change`] will fix it on startup.
167///
168/// As an additional safetynet this function creates backup copies of all files
169/// being overwritten. These will be deleted by [`finalize_password_change`]
170/// after the config has been read successfully for the first time after a
171/// password change.
172pub fn reencrypt_private_config(
173    data_dir: &Path,
174    private_config: &ServerConfigPrivate,
175    new_password: &str,
176) -> anyhow::Result<()> {
177    info!(target: LOG_CORE, ?data_dir, "Re-encrypting private config with new password");
178    let trimmed_password = trim_password(new_password);
179
180    // we keep the same salt so we don't have to atomically update 3 files, 2 is
181    // annoying enough (if we have to write the password file)
182    let salt = fs::read_to_string(data_dir.join(SALT_FILE))?;
183    let new_key = get_encryption_key(trimmed_password, &salt)?;
184
185    let password_file_path = data_dir.join(PLAINTEXT_PASSWORD);
186    let private_config_path = data_dir.join(PRIVATE_CONFIG).with_extension(ENCRYPTED_EXT);
187
188    // Make backup copies of all files to be overwritten
189    debug!(target: LOG_CORE, "Creating backup of private config");
190    let password_file_present = password_file_path.exists();
191    if password_file_present {
192        create_backup_copy(&password_file_path)?;
193    }
194    create_backup_copy(&private_config_path)?;
195
196    // Ensure backups are written durably before setting up password change
197    OpenOptions::new().read(true).open(data_dir)?.sync_all()?;
198
199    // Create new private config with updated password
200    let new_private_config = {
201        let mut new_private_config = private_config.clone();
202        new_private_config.api_auth = ApiAuth::new(trimmed_password.to_string());
203        new_private_config
204    };
205
206    // Write new files to temporary locations so they can be moved into place
207    // atomically later. This avoids data corruption if the process is killed while
208    // writing the files.
209    //
210    // Note that we write the password file first and later delete the private
211    // config file last. This way we can use the existence of the private config
212    // file to detect an interrupted password change and ensure it's driven to
213    // completion. We can't do the same with the password file since it might not be
214    // present at all. This also means that, if we see a stray temp password file,
215    // we can just delete it since the newly encrypted private config was never
216    // written, so the old password is still valid.
217    debug!(target: LOG_CORE, "Creating temporary files");
218    let temp_password_file_path = password_file_path.with_extension(NEW_VERSION_FILE_EXT);
219    if password_file_present {
220        write_new(&temp_password_file_path, trimmed_password)?;
221    }
222
223    let temp_private_config_path = private_config_path.with_extension(NEW_VERSION_FILE_EXT);
224    // We use the encrypted_write fn directly since the JSON version of it would
225    // overwrite the file extension.
226    let private_config_bytes = serde_json::to_string(&new_private_config)?.into_bytes();
227    encrypted_write(
228        private_config_bytes,
229        &new_key,
230        temp_private_config_path.clone(),
231    )?;
232
233    // Ensure temp files are written durably before starting to overwrite files
234    OpenOptions::new().read(true).open(data_dir)?.sync_all()?;
235
236    debug!(target: LOG_CORE, "Moving temp files to final location");
237    // Move new files into place. This can't be done atomically, so there's recovery
238    // logic in `recover_interrupted_password_change` on startup.
239    // DO NOT CHANGE MOVE ORDER, SEE ABOVE
240    fs::rename(&temp_private_config_path, &private_config_path)?;
241    if password_file_present {
242        fs::rename(&temp_password_file_path, &password_file_path)?;
243    }
244
245    Ok(())
246}
247
248/// If [`reencrypt_private_config`] was interrupted, this function ensures that
249/// the system is in a consistent state, either pre-password change or
250/// post-password change.
251pub fn recover_interrupted_password_change(data_dir: &Path) -> anyhow::Result<()> {
252    let password_file_path = data_dir.join(PLAINTEXT_PASSWORD);
253    let private_config_path = data_dir.join(PRIVATE_CONFIG).with_extension(ENCRYPTED_EXT);
254
255    let temp_password_file_path = password_file_path.with_extension(NEW_VERSION_FILE_EXT);
256    let temp_private_config_path = private_config_path.with_extension(NEW_VERSION_FILE_EXT);
257
258    match (
259        temp_private_config_path.exists(),
260        temp_password_file_path.exists(),
261    ) {
262        (false, false) => {
263            // Default case, nothing to do, no interrupted password change
264        }
265        (true, password_file_exists) => {
266            warn!(
267                target: LOG_CORE,
268                "Found temporary private config, password change process was interrupted. Recovering..."
269            );
270
271            // DO NOT CHANGE MOVE ORDER, SEE reencrypt_private_config
272            if password_file_exists {
273                fs::rename(&temp_password_file_path, &password_file_path)?;
274            }
275            fs::rename(&temp_private_config_path, &private_config_path)?;
276        }
277        (false, true) => {
278            warn!(
279                target: LOG_CORE,
280                "Found only the temporary password file but no encrypted config. Cleaning up the temporary password file."
281            );
282            fs::remove_file(&temp_password_file_path)?;
283        }
284    }
285
286    Ok(())
287}
288
289/// Clean up private config and password file backups after the config has been
290/// read successfully for the first time after a password change.
291pub fn finalize_password_change(data_dir: &Path) -> anyhow::Result<()> {
292    let password_backup_path = backup_copy_path(&data_dir.join(PLAINTEXT_PASSWORD));
293    if password_backup_path.exists() {
294        fs::remove_file(&password_backup_path)?;
295    }
296
297    let private_config_backup_path =
298        backup_copy_path(&data_dir.join(PRIVATE_CONFIG).with_extension(ENCRYPTED_EXT));
299    if private_config_backup_path.exists() {
300        fs::remove_file(&private_config_backup_path)?;
301    }
302
303    Ok(())
304}