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