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