From d0b0245edbb2f6ed8285358d83b98f3334bf1b12 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Fri, 9 Apr 2021 11:54:19 +0300 Subject: feat: add "obnam init" subcommand This reads a passphrase and derives two passwords from that, and stores them next to the configuration file. The passwords aren't yet used for anything, that will come later. --- Cargo.lock | 68 +++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 ++ obnam.md | 30 ++++++++++++++++++ src/backup_run.rs | 2 ++ src/bin/obnam.rs | 71 +++++++++++++++++++++++++---------------- src/client.rs | 47 +++++++++++++++++++++++++-- src/cmd/backup.rs | 2 ++ src/cmd/init.rs | 28 ++++++++++++++++ src/cmd/mod.rs | 3 ++ src/cmd/show_config.rs | 2 +- src/error.rs | 5 +++ src/lib.rs | 1 + src/passwords.rs | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++ subplot/data.py | 15 +++++++++ subplot/data.yaml | 6 ++++ 15 files changed, 337 insertions(+), 32 deletions(-) create mode 100644 src/cmd/init.rs create mode 100644 src/passwords.rs diff --git a/Cargo.lock b/Cargo.lock index 3e7d690..dc8a614 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,6 +65,12 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +[[package]] +name = "base64ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d27fb6b6f1e43147af148af49d49329413ba781aa0d5e10979831c210173b5" + [[package]] name = "bitflags" version = "1.2.1" @@ -203,6 +209,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8aebca1129a03dc6dc2b127edd729435bbc4a37e1d5f4d7513165089ceb02634" +[[package]] +name = "crypto-mac" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "derivative" version = "2.2.0" @@ -533,6 +549,16 @@ dependencies = [ "libc", ] +[[package]] +name = "hmac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" +dependencies = [ + "crypto-mac", + "digest", +] + [[package]] name = "http" version = "0.2.3" @@ -1008,8 +1034,11 @@ dependencies = [ "libc", "log", "log4rs", + "pbkdf2", "pretty_env_logger", + "rand 0.8.3", "reqwest", + "rpassword", "rusqlite", "serde", "serde_json", @@ -1104,6 +1133,29 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "password-hash" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85d8faea6c018131952a192ee55bd9394c51fc6f63294b668d97636e6f842d40" +dependencies = [ + "base64ct", + "rand_core 0.6.2", +] + +[[package]] +name = "pbkdf2" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf916dd32dd26297907890d99dc2740e33f6bd9073965af4ccff2967962f5508" +dependencies = [ + "base64ct", + "crypto-mac", + "hmac", + "password-hash", + "sha2", +] + [[package]] name = "percent-encoding" version = "2.1.0" @@ -1420,6 +1472,16 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "rpassword" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +dependencies = [ + "libc", + "winapi 0.3.9", +] + [[package]] name = "rusqlite" version = "0.24.2" @@ -1686,6 +1748,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e81da0851ada1f3e9d4312c704aa4f8806f0f9d69faaf8df2f3464b4a9437c2" + [[package]] name = "syn" version = "1.0.64" diff --git a/Cargo.toml b/Cargo.toml index 394c1bb..180d342 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,8 +18,11 @@ indicatif = "0.15" libc = "0.2" log = "0.4" log4rs = "1" +pbkdf2 = "0.7" pretty_env_logger = "0.4" +rand = "0.8" reqwest = { version = "0.11", features = ["blocking", "json"]} +rpassword = "5" rusqlite = "0.24" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/obnam.md b/obnam.md index 959415d..7e07c50 100644 --- a/obnam.md +++ b/obnam.md @@ -1118,6 +1118,7 @@ then stdout, as JSON, matches file config.json roots: [live] server_url: https://backup.example.com verify_tls_cert: true +encrypt: false ~~~ @@ -1531,11 +1532,40 @@ Verify that trying to backup without having set a passphrase fails with an error message that clearly identifies the lack of a passphrase. +~~~scenario +given an installed obnam +and a running chunk server +and a client config based on encryption.yaml +and a file live/data.dat containing some random data +and a manifest of the directory live in live.yaml +when I try to run obnam --config encryption.yaml backup +then command fails +then stderr contains "obnam init" +~~~ + +~~~{#encryption.yaml .file .yaml .numberLines} +verify_tls_cert: false +roots: [live] +encrypt: true +~~~ + ## A passphrase can be set Set a passphrase. Verify that it's stored in a file that is only readable by it owner. Verify that a backup can be made. +~~~scenario +given an installed obnam +and a running chunk server +and a client config based on encryption.yaml +and a file live/data.dat containing some random data +and a manifest of the directory live in live.yaml +when I run obnam --config encryption.yaml init --insecure-passphrase=hunter2 +then file passwords.yaml exists +then file passwords.yaml is only readable by owner +then file passwords.yaml does not contain "hunter2" +~~~ + ## A passphrase stored insecurely is rejected Verify that a backup fails if the file where the passphrase is stored diff --git a/src/backup_run.rs b/src/backup_run.rs index c5d5382..e9d094d 100644 --- a/src/backup_run.rs +++ b/src/backup_run.rs @@ -40,6 +40,7 @@ pub type BackupResult = Result; impl<'a> InitialBackup<'a> { pub fn new(config: &ClientConfig, client: &'a BackupClient) -> BackupResult { let progress = BackupProgress::initial(); + let config = config.config(); Ok(Self { client, buffer_size: config.chunk_size, @@ -79,6 +80,7 @@ impl<'a> InitialBackup<'a> { impl<'a> IncrementalBackup<'a> { pub fn new(config: &ClientConfig, client: &'a BackupClient) -> BackupResult { + let config = config.config(); let policy = BackupPolicy::default(); Ok(Self { client, diff --git a/src/bin/obnam.rs b/src/bin/obnam.rs index 72ce7c2..2dbbaa2 100644 --- a/src/bin/obnam.rs +++ b/src/bin/obnam.rs @@ -1,10 +1,11 @@ -use anyhow::Context; use directories_next::ProjectDirs; use log::{debug, error, info, LevelFilter}; use log4rs::append::file::FileAppender; use log4rs::config::{Appender, Config, Logger, Root}; use obnam::client::ClientConfig; -use obnam::cmd::{backup, get_chunk, list, list_files, restore, show_config, show_generation}; +use obnam::cmd::{ + backup, get_chunk, init, list, list_files, restore, show_config, show_generation, +}; use std::path::{Path, PathBuf}; use structopt::StructOpt; @@ -14,21 +15,33 @@ const APPLICATION: &str = "obnam"; fn main() -> anyhow::Result<()> { let opt = Opt::from_args(); - let config = load_config(&opt)?; - setup_logging(&config.log)?; - debug!("configuration: {:#?}", config); + let config = load_config_without_passwords(&opt)?; + setup_logging(&config.config().log)?; info!("client starts"); debug!("{:?}", opt); + debug!("configuration: {:#?}", config); - let result = match opt.cmd { - Command::Backup => backup(&config), - Command::List => list(&config), - Command::ShowGeneration { gen_id } => show_generation(&config, &gen_id), - Command::ListFiles { gen_id } => list_files(&config, &gen_id), - Command::Restore { gen_id, to } => restore(&config, &gen_id, &to), - Command::GetChunk { chunk_id } => get_chunk(&config, &chunk_id), - Command::Config => show_config(&config), + let cfgname = config_filename(&opt); + let result = if let Command::Init { + insecure_passphrase, + } = opt.cmd + { + init(config.config(), &cfgname, insecure_passphrase) + } else { + let config = load_config_with_passwords(&opt)?; + match opt.cmd { + Command::Init { + insecure_passphrase: _, + } => panic!("this cannot happen"), + Command::Backup => backup(&config), + Command::List => list(&config), + Command::ShowGeneration { gen_id } => show_generation(&config, &gen_id), + Command::ListFiles { gen_id } => list_files(&config, &gen_id), + Command::Restore { gen_id, to } => restore(&config, &gen_id, &to), + Command::GetChunk { chunk_id } => get_chunk(&config, &chunk_id), + Command::Config => show_config(&config), + } }; if let Err(ref e) = result { @@ -40,21 +53,19 @@ fn main() -> anyhow::Result<()> { Ok(()) } -fn load_config(opt: &Opt) -> Result { - let config = match opt.config { - None => { - let filename = default_config(); - ClientConfig::read_config(&filename).with_context(|| { - format!( - "Couldn't read default configuration file {}", - filename.display() - ) - })? - } - Some(ref filename) => ClientConfig::read_config(&filename) - .with_context(|| format!("Couldn't read configuration file {}", filename.display()))?, - }; - Ok(config) +fn load_config_with_passwords(opt: &Opt) -> Result { + Ok(ClientConfig::read_with_passwords(&config_filename(opt))?) +} + +fn load_config_without_passwords(opt: &Opt) -> Result { + Ok(ClientConfig::read_without_passwords(&config_filename(opt))?) +} + +fn config_filename(opt: &Opt) -> PathBuf { + match opt.config { + None => default_config(), + Some(ref filename) => filename.to_path_buf(), + } } fn default_config() -> PathBuf { @@ -77,6 +88,10 @@ struct Opt { #[derive(Debug, StructOpt)] enum Command { + Init { + #[structopt(long)] + insecure_passphrase: Option, + }, Backup, List, ListFiles { diff --git a/src/client.rs b/src/client.rs index d513011..37bfe91 100644 --- a/src/client.rs +++ b/src/client.rs @@ -7,6 +7,7 @@ use crate::chunkmeta::ChunkMeta; use crate::fsentry::{FilesystemEntry, FilesystemKind}; use crate::generation::{FinishedGeneration, LocalGeneration, LocalGenerationError}; use crate::genlist::GenerationList; +use crate::passwords::{passwords_filename, PasswordError, Passwords}; use bytesize::MIB; use chrono::{DateTime, Local}; @@ -29,15 +30,48 @@ struct TentativeClientConfig { chunk_size: Option, roots: Vec, log: Option, + encrypt: Option, } #[derive(Debug, Serialize, Clone)] -pub struct ClientConfig { +pub enum ClientConfig { + Plain(ClientConfigWithoutPasswords), + WithPasswords(ClientConfigWithoutPasswords, Passwords), +} + +impl ClientConfig { + pub fn read_without_passwords(filename: &Path) -> Result { + let config = ClientConfigWithoutPasswords::read_config(filename)?; + Ok(ClientConfig::Plain(config)) + } + + pub fn read_with_passwords(filename: &Path) -> Result { + let config = ClientConfigWithoutPasswords::read_config(filename)?; + if config.encrypt { + let passwords = Passwords::load(&passwords_filename(filename)) + .map_err(ClientConfigError::PasswordsMissing)?; + Ok(ClientConfig::WithPasswords(config, passwords)) + } else { + Ok(ClientConfig::Plain(config)) + } + } + + pub fn config(&self) -> &ClientConfigWithoutPasswords { + match self { + Self::Plain(config) => &config, + Self::WithPasswords(config, _) => &config, + } + } +} + +#[derive(Debug, Serialize, Clone)] +pub struct ClientConfigWithoutPasswords { pub server_url: String, pub verify_tls_cert: bool, pub chunk_size: usize, pub roots: Vec, pub log: PathBuf, + pub encrypt: bool, } #[derive(Debug, thiserror::Error)] @@ -51,6 +85,9 @@ pub enum ClientConfigError { #[error("server URL doesn't use https: {0}")] NotHttps(String), + #[error("No passwords are set: you may need to run 'obnam init': {0}")] + PasswordsMissing(PasswordError), + #[error(transparent)] IoError(#[from] std::io::Error), @@ -60,13 +97,15 @@ pub enum ClientConfigError { pub type ClientConfigResult = Result; -impl ClientConfig { +impl ClientConfigWithoutPasswords { pub fn read_config(filename: &Path) -> ClientConfigResult { trace!("read_config: filename={:?}", filename); let config = std::fs::read_to_string(filename)?; let tentative: TentativeClientConfig = serde_yaml::from_str(&config)?; - let config = ClientConfig { + let encrypt = tentative.encrypt.or(Some(false)).unwrap(); + + let config = Self { server_url: tentative.server_url, roots: tentative.roots, verify_tls_cert: tentative.verify_tls_cert.or(Some(false)).unwrap(), @@ -75,6 +114,7 @@ impl ClientConfig { .log .or_else(|| Some(PathBuf::from(DEVNULL))) .unwrap(), + encrypt, }; config.check()?; @@ -147,6 +187,7 @@ pub struct BackupClient { impl BackupClient { pub fn new(config: &ClientConfig) -> ClientResult { info!("creating backup client with config: {:#?}", config); + let config = config.config(); let client = Client::builder() .danger_accept_invalid_certs(!config.verify_tls_cert) .build()?; diff --git a/src/cmd/backup.rs b/src/cmd/backup.rs index e6781e9..bd36a3a 100644 --- a/src/cmd/backup.rs +++ b/src/cmd/backup.rs @@ -53,6 +53,7 @@ fn initial_backup( info!("fresh backup without a previous generation"); let newtemp = NamedTempFile::new()?; let run = InitialBackup::new(config, &client)?; + let config = config.config(); let mut all_warnings = vec![]; let count = { let mut new = NascentGeneration::create(newtemp.path())?; @@ -79,6 +80,7 @@ fn incremental_backup( info!("incremental backup based on {}", old_ref); let newtemp = NamedTempFile::new()?; let mut run = IncrementalBackup::new(config, &client)?; + let config = config.config(); let mut all_warnings = vec![]; let count = { let oldtemp = NamedTempFile::new()?; diff --git a/src/cmd/init.rs b/src/cmd/init.rs new file mode 100644 index 0000000..f0ddb69 --- /dev/null +++ b/src/cmd/init.rs @@ -0,0 +1,28 @@ +use crate::client::ClientConfigWithoutPasswords; +use crate::error::ObnamError; +use crate::passwords::{passwords_filename, Passwords}; +use std::path::Path; + +const PROMPT: &str = "Obnam passphrase: "; + +pub fn init( + config: &ClientConfigWithoutPasswords, + config_filename: &Path, + insecure_passphrase: Option, +) -> Result<(), ObnamError> { + if !config.encrypt { + panic!("no encryption specified"); + } + + let passphrase = match insecure_passphrase { + Some(x) => x, + None => rpassword::read_password_from_tty(Some(PROMPT)).unwrap(), + }; + + let passwords = Passwords::new(&passphrase); + let filename = passwords_filename(config_filename); + passwords + .save(&filename) + .map_err(|err| ObnamError::PasswordSave(filename, err))?; + Ok(()) +} diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index fc517be..70dde59 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1,3 +1,6 @@ +mod init; +pub use init::init; + mod backup; pub use backup::backup; diff --git a/src/cmd/show_config.rs b/src/cmd/show_config.rs index b4f4cdc..8acd1c8 100644 --- a/src/cmd/show_config.rs +++ b/src/cmd/show_config.rs @@ -2,6 +2,6 @@ use crate::client::ClientConfig; use crate::error::ObnamError; pub fn show_config(config: &ClientConfig) -> Result<(), ObnamError> { - println!("{}", serde_json::to_string_pretty(config)?); + println!("{}", serde_json::to_string_pretty(&config.config())?); Ok(()) } diff --git a/src/error.rs b/src/error.rs index a905458..454bba6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,6 +3,8 @@ use crate::client::{ClientConfigError, ClientError}; use crate::cmd::restore::RestoreError; use crate::generation::{LocalGenerationError, NascentError}; use crate::genlist::GenerationListError; +use crate::passwords::PasswordError; +use std::path::PathBuf; use std::time::SystemTimeError; use tempfile::PersistError; @@ -13,6 +15,9 @@ pub enum ObnamError { #[error(transparent)] GenerationListError(#[from] GenerationListError), + #[error("couldn't save passwords to {0}: {1}")] + PasswordSave(PathBuf, PasswordError), + #[error(transparent)] ClientError(#[from] ClientError), diff --git a/src/lib.rs b/src/lib.rs index a12b8a3..fb4c7fe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ pub mod generation; pub mod genlist; pub mod index; pub mod indexedstore; +pub mod passwords; pub mod policy; pub mod server; pub mod store; diff --git a/src/passwords.rs b/src/passwords.rs new file mode 100644 index 0000000..b8ca3f5 --- /dev/null +++ b/src/passwords.rs @@ -0,0 +1,86 @@ +use pbkdf2::{ + password_hash::{PasswordHasher, SaltString}, + Pbkdf2, +}; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; +use std::io::prelude::Write; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Passwords { + encryption: String, + mac: String, +} + +impl Passwords { + pub fn new(passphrase: &str) -> Self { + Self { + encryption: derive_password(passphrase), + mac: derive_password(passphrase), + } + } + + pub fn load(filename: &Path) -> Result { + let data = std::fs::read(filename) + .map_err(|err| PasswordError::Read(filename.to_path_buf(), err))?; + serde_yaml::from_slice(&data) + .map_err(|err| PasswordError::Parse(filename.to_path_buf(), err)) + } + + pub fn save(&self, filename: &Path) -> Result<(), PasswordError> { + eprintln!("saving passwords to {:?}", filename); + + let data = serde_yaml::to_string(&self).map_err(PasswordError::Serialize)?; + + let mut file = std::fs::File::create(filename) + .map_err(|err| PasswordError::Write(filename.to_path_buf(), err))?; + let metadata = file + .metadata() + .map_err(|err| PasswordError::Write(filename.to_path_buf(), err))?; + let mut permissions = metadata.permissions(); + + // Make readadable by owner only. We still have the open file + // handle, so we can write the content. + permissions.set_mode(0o400); + std::fs::set_permissions(filename, permissions) + .map_err(|err| PasswordError::Write(filename.to_path_buf(), err))?; + + // Write actual content. + file.write_all(data.as_bytes()) + .map_err(|err| PasswordError::Write(filename.to_path_buf(), err))?; + + Ok(()) + } +} + +pub fn passwords_filename(config_filename: &Path) -> PathBuf { + let mut filename = config_filename.to_path_buf(); + filename.set_file_name("passwords.yaml"); + filename +} + +fn derive_password(passphrase: &str) -> String { + let salt = SaltString::generate(&mut OsRng); + + Pbkdf2 + .hash_password_simple(passphrase.as_bytes(), salt.as_ref()) + .unwrap() + .to_string() +} + +#[derive(Debug, thiserror::Error)] +pub enum PasswordError { + #[error("failed to serialize passwords for saving: {0}")] + Serialize(serde_yaml::Error), + + #[error("failed to save passwords to {0}: {1}")] + Write(PathBuf, std::io::Error), + + #[error("failed to read passwords from {0}: {1}")] + Read(PathBuf, std::io::Error), + + #[error("failed to parse saved passwords from {0}: {1}")] + Parse(PathBuf, serde_yaml::Error), +} diff --git a/subplot/data.py b/subplot/data.py index 1455bf4..3833f2e 100644 --- a/subplot/data.py +++ b/subplot/data.py @@ -3,6 +3,7 @@ import logging import os import random import socket +import stat import yaml @@ -148,3 +149,17 @@ def manifests_match(ctx, expected=None, actual=None): assert_eq(actual_objs, []) logging.debug(f"manifests {expected} and {actual} match") + + +def file_is_readable_by_owner(ctx, filename=None): + assert_eq = globals()["assert_eq"] + + st = os.lstat(filename) + mode = stat.S_IMODE(st.st_mode) + logging.debug("file mode: %o", mode) + assert_eq(mode, 0o400) + + +def file_does_not_contain(ctx, filename=None, pattern=None): + data = open(filename).read() + assert pattern not in data diff --git a/subplot/data.yaml b/subplot/data.yaml index 64348d6..699c5b1 100644 --- a/subplot/data.yaml +++ b/subplot/data.yaml @@ -39,3 +39,9 @@ - then: "manifests {expected} and {actual} match" function: manifests_match + +- then: "file {filename} is only readable by owner" + function: file_is_readable_by_owner + +- then: "file {filename} does not contain \"{pattern:text}\"" + function: file_does_not_contain -- cgit v1.2.1