summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2021-04-09 14:52:03 +0000
committerLars Wirzenius <liw@liw.fi>2021-04-09 14:52:03 +0000
commita7bbd6d91139093b488c9ac4b543f24ede23a495 (patch)
tree286d2c03c2346b195c2989488d4da1e967a7c2e9
parent2d6c1c81bfb1c0be8dfaced1c70e825e46c66430 (diff)
parenta742941804dd6f4b1ea3f39dfe3c7072b4b50e6b (diff)
downloadobnam2-a7bbd6d91139093b488c9ac4b543f24ede23a495.tar.gz
Merge branch 'init' into 'main'
feat: add "obnam init" subcommand Closes #100 See merge request larswirzenius/obnam!133
-rw-r--r--Cargo.lock68
-rw-r--r--Cargo.toml3
-rw-r--r--obnam.md104
-rw-r--r--src/backup_run.rs2
-rw-r--r--src/bin/obnam.rs71
-rw-r--r--src/client.rs47
-rw-r--r--src/cmd/backup.rs2
-rw-r--r--src/cmd/init.rs28
-rw-r--r--src/cmd/mod.rs3
-rw-r--r--src/cmd/show_config.rs2
-rw-r--r--src/error.rs5
-rw-r--r--src/lib.rs1
-rw-r--r--src/passwords.rs86
-rw-r--r--subplot/client.py33
-rw-r--r--subplot/client.yaml4
-rw-r--r--subplot/data.py15
-rw-r--r--subplot/data.yaml6
17 files changed, 387 insertions, 93 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 3e7d690..dc8a614 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -66,6 +66,12 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -204,6 +210,16 @@ 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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -534,6 +550,16 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1008,8 +1034,11 @@ dependencies = [
"libc",
"log",
"log4rs",
+ "pbkdf2",
"pretty_env_logger",
+ "rand 0.8.3",
"reqwest",
+ "rpassword",
"rusqlite",
"serde",
"serde_json",
@@ -1105,6 +1134,29 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1421,6 +1473,16 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1687,6 +1749,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
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..e5a5447 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
~~~
@@ -1152,7 +1153,7 @@ given an installed obnam
and a running chunk server
and a client config based on ca-required.yaml
and a file live/data.dat containing some random data
-when I try to run obnam --config ca-required.yaml backup
+when I try to run obnam backup
then command fails
then stderr contains "self signed certificate"
~~~
@@ -1181,11 +1182,11 @@ and a running chunk server
and a client config based on smoke.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 smoke.yaml backup
+when I run obnam backup
then backup generation is GEN
-when I run obnam --config smoke.yaml list
+when I run obnam list
then generation list contains <GEN>
-when I invoke obnam --config smoke.yaml restore <GEN> rest
+when I invoke obnam restore <GEN> rest
given a manifest of the directory live restored in rest in rest.yaml
then manifests live.yaml and rest.yaml match
~~~
@@ -1220,9 +1221,9 @@ and a running chunk server
and a client config based on metadata.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 metadata.yaml backup
+when I run obnam backup
then backup generation is GEN
-when I invoke obnam --config metadata.yaml restore <GEN> rest
+when I invoke obnam restore <GEN> rest
given a manifest of the directory live restored in rest in rest.yaml
then manifests live.yaml and rest.yaml match
~~~
@@ -1239,9 +1240,9 @@ and a client config based on metadata.yaml
and a file live/data.dat containing some random data
and file live/data.dat has mode 464
and a manifest of the directory live in live.yaml
-when I run obnam --config metadata.yaml backup
+when I run obnam backup
then backup generation is GEN
-when I invoke obnam --config metadata.yaml restore <GEN> rest
+when I invoke obnam restore <GEN> rest
given a manifest of the directory live restored in rest in rest.yaml
then manifests live.yaml and rest.yaml match
~~~
@@ -1258,9 +1259,9 @@ and a file live/data.dat containing some random data
and symbolink link live/link that points at data.dat
and symbolink link live/broken that points at does-not-exist
and a manifest of the directory live in live.yaml
-when I run obnam --config metadata.yaml backup
+when I run obnam backup
then backup generation is GEN
-when I invoke obnam --config metadata.yaml restore <GEN> rest
+when I invoke obnam restore <GEN> rest
given a manifest of the directory live restored in rest in rest.yaml
then manifests live.yaml and rest.yaml match
~~~
@@ -1276,7 +1277,7 @@ given an installed obnam
given a running chunk server
given a client config based on tiny-chunk-size.yaml
given a file live/data.dat containing "abc"
-when I run obnam --config tiny-chunk-size.yaml backup
+when I run obnam backup
then server has 3 file chunks
~~~
@@ -1304,8 +1305,8 @@ and a running chunk server
and a client config based on smoke.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 smoke.yaml backup
-when I run obnam --config smoke.yaml list-files
+when I run obnam backup
+when I run obnam list-files
then file live/data.dat was backed up because it was new
~~~
@@ -1320,9 +1321,9 @@ and a running chunk server
and a client config based on smoke.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 smoke.yaml backup
-when I run obnam --config smoke.yaml backup
-when I run obnam --config smoke.yaml list-files
+when I run obnam backup
+when I run obnam backup
+when I run obnam list-files
then file live/data.dat was not backed up because it was unchanged
~~~
@@ -1337,10 +1338,10 @@ and a running chunk server
and a client config based on smoke.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 smoke.yaml backup
+when I run obnam backup
given a file live/data.dat containing some random data
-when I run obnam --config smoke.yaml backup
-when I run obnam --config smoke.yaml list-files
+when I run obnam backup
+when I run obnam list-files
then file live/data.dat was backed up because it was changed
~~~
@@ -1355,12 +1356,12 @@ given an installed obnam
and a running chunk server
and a client config based on smoke.yaml
and a file live/data.dat containing some random data
-when I run obnam --config smoke.yaml backup
+when I run obnam backup
then backup generation is GEN
-when I invoke obnam --config smoke.yaml get-chunk <GEN>
+when I invoke obnam get-chunk <GEN>
then exit code is 0
when chunk <GEN> on chunk server is replaced by an empty file
-when I invoke obnam --config smoke.yaml get-chunk <GEN>
+when I invoke obnam get-chunk <GEN>
then command fails
~~~
@@ -1380,8 +1381,8 @@ and a file live/data.dat containing some random data
and a Unix socket live/socket
and a named pipe live/pipe
and a manifest of the directory live in live.yaml
-when I run obnam --config smoke.yaml backup
-when I run obnam --config smoke.yaml restore latest rest
+when I run obnam backup
+when I run obnam restore latest rest
given a manifest of the directory live restored in rest in rest.yaml
then manifests live.yaml and rest.yaml match
~~~
@@ -1399,9 +1400,9 @@ and a running chunk server
and a client config based on metadata.yaml
and a file in live with a non-UTF8 filename
and a manifest of the directory live in live.yaml
-when I run obnam --config metadata.yaml backup
+when I run obnam backup
then backup generation is GEN
-when I invoke obnam --config metadata.yaml restore <GEN> rest
+when I invoke obnam restore <GEN> rest
given a manifest of the directory live restored in rest in rest.yaml
then manifests live.yaml and rest.yaml match
~~~
@@ -1421,9 +1422,9 @@ and a client config based on smoke.yaml
and a file live/data.dat containing some random data
and a file live/bad.dat containing some random data
and file live/bad.dat has mode 000
-when I run obnam --config smoke.yaml backup
+when I run obnam backup
then backup generation is GEN
-when I invoke obnam --config smoke.yaml restore <GEN> rest
+when I invoke obnam restore <GEN> rest
then file live/data.dat is restored to rest
then file live/bad.dat is not restored to rest
~~~
@@ -1439,10 +1440,10 @@ and a running chunk server
and a client config based on smoke.yaml
and a file live/unreadable/data.dat containing some random data
and file live/unreadable has mode 000
-when I run obnam --config smoke.yaml backup
+when I run obnam backup
then stdout contains "live/unreadable"
then backup generation is GEN
-when I invoke obnam --config smoke.yaml restore <GEN> rest
+when I invoke obnam restore <GEN> rest
then file live/unreadable is restored to rest
then file live/unreadable/data.dat is not restored to rest
~~~
@@ -1458,10 +1459,10 @@ and a running chunk server
and a client config based on smoke.yaml
and a file live/dir/data.dat containing some random data
and file live/dir has mode 600
-when I run obnam --config smoke.yaml backup
+when I run obnam backup
then stdout contains "live/dir"
then backup generation is GEN
-when I invoke obnam --config smoke.yaml restore <GEN> rest
+when I invoke obnam restore <GEN> rest
then file live/dir is restored to rest
then file live/dir/data.dat is not restored to rest
~~~
@@ -1478,13 +1479,13 @@ and a running chunk server
and a client config based on metadata.yaml
given a file live/data.dat containing some random data
-when I run obnam --config metadata.yaml backup
+when I run obnam backup
given a file live/more.dat containing some random data
and a manifest of the directory live in second.yaml
-when I run obnam --config metadata.yaml backup
+when I run obnam backup
-when I run obnam --config metadata.yaml restore latest rest
+when I run obnam restore latest rest
given a manifest of the directory live restored in rest in rest.yaml
then manifests second.yaml and rest.yaml match
~~~
@@ -1503,9 +1504,9 @@ and a file live/one/data.dat containing some random data
and a file live/two/data.dat containing some random data
and a manifest of the directory live/one in one.yaml
and a manifest of the directory live/two in two.yaml
-when I run obnam --config roots.yaml backup
+when I run obnam backup
then backup generation is GEN
-when I invoke obnam --config roots.yaml restore <GEN> rest
+when I invoke obnam restore <GEN> rest
given a manifest of the directory live/one restored in rest in rest-one.yaml
given a manifest of the directory live/two restored in rest in rest-two.yaml
then manifests one.yaml and rest-one.yaml match
@@ -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 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 init --insecure-passphrase=hunter2
+then file .config/obnam/passwords.yaml exists
+then file .config/obnam/passwords.yaml is only readable by owner
+then file .config/obnam/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<T> = Result<T, BackupError>;
impl<'a> InitialBackup<'a> {
pub fn new(config: &ClientConfig, client: &'a BackupClient) -> BackupResult<Self> {
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<Self> {
+ 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<ClientConfig, anyhow::Error> {
- 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<ClientConfig, anyhow::Error> {
+ Ok(ClientConfig::read_with_passwords(&config_filename(opt))?)
+}
+
+fn load_config_without_passwords(opt: &Opt) -> Result<ClientConfig, anyhow::Error> {
+ 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<String>,
+ },
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<usize>,
roots: Vec<PathBuf>,
log: Option<PathBuf>,
+ encrypt: Option<bool>,
}
#[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<Self, ClientConfigError> {
+ let config = ClientConfigWithoutPasswords::read_config(filename)?;
+ Ok(ClientConfig::Plain(config))
+ }
+
+ pub fn read_with_passwords(filename: &Path) -> Result<Self, ClientConfigError> {
+ 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<PathBuf>,
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<T> = Result<T, ClientConfigError>;
-impl ClientConfig {
+impl ClientConfigWithoutPasswords {
pub fn read_config(filename: &Path) -> ClientConfigResult<Self> {
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<Self> {
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<String>,
+) -> 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<Self, PasswordError> {
+ 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/client.py b/subplot/client.py
index 90642f3..1ddc772 100644
--- a/subplot/client.py
+++ b/subplot/client.py
@@ -26,38 +26,27 @@ def configure_client(ctx, filename=None):
config["server_url"] = ctx["server_url"]
logging.debug(f"client config {filename}: {config}")
+ dirname = os.path.expanduser("~/.config/obnam")
+ if not os.path.exists(dirname):
+ os.makedirs(dirname)
+ filename = os.path.join(dirname, "obnam.yaml")
+ logging.debug(f"configure_client: filename={filename}")
with open(filename, "w") as f:
yaml.safe_dump(config, stream=f)
-def run_obnam_restore(ctx, filename=None, genid=None, todir=None):
- genid = ctx["vars"][genid]
- run_obnam_restore_with_genref(ctx, filename=filename, genref=genid, todir=todir)
+def run_obnam_restore(ctx, genid=None, todir=None):
+ runcmd_run = globals()["runcmd_run"]
+ genref = ctx["vars"][genid]
+ runcmd_run(ctx, ["env", "RUST_LOG=obnam", "obnam", "restore", genref, todir])
-def run_obnam_restore_with_genref(ctx, filename=None, genref=None, todir=None):
- runcmd_run = globals()["runcmd_run"]
- runcmd_run(
- ctx,
- [
- "env",
- "RUST_LOG=obnam",
- "obnam",
- "--config",
- filename,
- "restore",
- genref,
- todir,
- ],
- )
-
-
-def run_obnam_get_chunk(ctx, filename=None, gen_id=None, todir=None):
+def run_obnam_get_chunk(ctx, gen_id=None, todir=None):
runcmd_run = globals()["runcmd_run"]
gen_id = ctx["vars"][gen_id]
logging.debug(f"run_obnam_get_chunk: gen_id={gen_id}")
- runcmd_run(ctx, ["obnam", "--config", filename, "get-chunk", gen_id])
+ runcmd_run(ctx, ["obnam", "get-chunk", gen_id])
def capture_generation_id(ctx, varname=None):
diff --git a/subplot/client.yaml b/subplot/client.yaml
index eba9212..8c76e9f 100644
--- a/subplot/client.yaml
+++ b/subplot/client.yaml
@@ -5,10 +5,10 @@
- given: "a client config based on {filename}"
function: configure_client
-- when: "I invoke obnam --config {filename} restore <{genid}> {todir}"
+- when: "I invoke obnam restore <{genid}> {todir}"
function: run_obnam_restore
-- when: "I invoke obnam --config {filename} get-chunk <{gen_id}>"
+- when: "I invoke obnam get-chunk <{gen_id}>"
function: run_obnam_get_chunk
- then: "backup generation is {varname}"
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