mirror of
https://github.com/GothenburgBitFactory/taskwarrior.git
synced 2025-06-26 10:54:26 +02:00
Encrypt content sent to the server
This implements client-side encryption, so that users' task information is not availble to the server (or to anyone who does not have the `encryption_secret`).
This commit is contained in:
parent
6b70b47aa0
commit
a8d45c67c6
8 changed files with 206 additions and 26 deletions
19
Cargo.lock
generated
19
Cargo.lock
generated
|
@ -1642,6 +1642,12 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "protobuf"
|
||||||
|
version = "2.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "da78e04bc0e40f36df43ecc6575e4f4b180e8156c4efd73f13d5619479b05696"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "publicsuffix"
|
name = "publicsuffix"
|
||||||
version = "1.5.4"
|
version = "1.5.4"
|
||||||
|
@ -2255,6 +2261,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempdir",
|
"tempdir",
|
||||||
|
"tindercrypt",
|
||||||
"ureq",
|
"ureq",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
@ -2456,6 +2463,18 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tindercrypt"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f34fd5cc8db265f27abf29e8a3cec5cc643ae9f3f9ae39f08a77dc4add375b6d"
|
||||||
|
dependencies = [
|
||||||
|
"protobuf",
|
||||||
|
"rand 0.7.3",
|
||||||
|
"ring",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use crate::argparse::{Command, Subcommand};
|
use crate::argparse::{Command, Subcommand};
|
||||||
use config::Config;
|
use config::Config;
|
||||||
use failure::Fallible;
|
use failure::{format_err, Fallible};
|
||||||
use taskchampion::{server, Replica, ReplicaConfig, ServerConfig, Uuid};
|
use taskchampion::{server, Replica, ReplicaConfig, ServerConfig, Uuid};
|
||||||
use termcolor::{ColorChoice, StandardStream};
|
use termcolor::{ColorChoice, StandardStream};
|
||||||
|
|
||||||
|
@ -113,12 +113,16 @@ fn get_server(settings: &Config) -> Fallible<Box<dyn server::Server>> {
|
||||||
settings.get_str("server_origin"),
|
settings.get_str("server_origin"),
|
||||||
) {
|
) {
|
||||||
let client_id = Uuid::parse_str(&client_id)?;
|
let client_id = Uuid::parse_str(&client_id)?;
|
||||||
|
let encryption_secret = settings
|
||||||
|
.get_str("encryption_secret")
|
||||||
|
.map_err(|_| format_err!("Could not read `encryption_secret` configuration"))?;
|
||||||
|
|
||||||
log::debug!("Using sync-server with origin {}", origin);
|
log::debug!("Using sync-server with origin {}", origin);
|
||||||
log::debug!("Sync client ID: {}", client_id);
|
log::debug!("Sync client ID: {}", client_id);
|
||||||
Ok(server::from_config(ServerConfig::Remote {
|
Ok(server::from_config(ServerConfig::Remote {
|
||||||
origin,
|
origin,
|
||||||
client_id,
|
client_id,
|
||||||
|
encryption_secret: encryption_secret.as_bytes().to_vec(),
|
||||||
})?)
|
})?)
|
||||||
} else {
|
} else {
|
||||||
let server_dir = settings.get_str("server_dir")?.into();
|
let server_dir = settings.get_str("server_dir")?.into();
|
||||||
|
|
|
@ -27,6 +27,11 @@ The following configuration parameters are available:
|
||||||
* `server_dir` - path to a directory containing the local server's data.
|
* `server_dir` - path to a directory containing the local server's data.
|
||||||
This is only used if `server_origin` or `server_client_id` are not set.
|
This is only used if `server_origin` or `server_client_id` are not set.
|
||||||
Default: `taskchampion-sync-server` in the local data directory.
|
Default: `taskchampion-sync-server` in the local data directory.
|
||||||
|
* `encryption_secret` - Secret value used to encrypt all data stored on the server.
|
||||||
|
This should be a long random string.
|
||||||
|
If you have `openssl` installed, a command like `openssl rand -hex 35` will generate a suitable value.
|
||||||
|
This value is only used when synchronizing with a remote server -- local servers are unencrypted.
|
||||||
|
Treat this value as a password.
|
||||||
* `server_origin` - Origin of the TaskChampion sync server, e.g., `https://taskchampion.example.com`.
|
* `server_origin` - Origin of the TaskChampion sync server, e.g., `https://taskchampion.example.com`.
|
||||||
If not set, then sync is done to a local server.
|
If not set, then sync is done to a local server.
|
||||||
* `server_client_id` - Client ID to identify this replica to the sync server (a UUID)
|
* `server_client_id` - Client ID to identify this replica to the sync server (a UUID)
|
||||||
|
@ -34,16 +39,26 @@ The following configuration parameters are available:
|
||||||
|
|
||||||
### Synchronization
|
### Synchronization
|
||||||
|
|
||||||
A TaskChampion replica "synchronizes" its local task database with other replicas via a sync server.
|
A single TaskChampion task database is known as a "replica".
|
||||||
|
A replica "synchronizes" its local information with other replicas via a sync server.
|
||||||
|
Many replicas can thus share the same task history.
|
||||||
|
|
||||||
This operation is triggered by running `task sync`.
|
This operation is triggered by running `task sync`.
|
||||||
Typically this runs frequently in a cron task.
|
Typically this runs frequently in a cron task.
|
||||||
The operation is quick, especially if no changes have occurred.
|
Synchronization is quick, especially if no changes have occurred.
|
||||||
|
|
||||||
The replica expects to be synchronized frequently, even if no server is involved.
|
Each replica expects to be synchronized frequently, even if no server is involved.
|
||||||
Without periodic syncs, the storage space used for the task database will grow quickly, and performance will suffer.
|
Without periodic syncs, the storage space used for the task database will grow quickly, and performance will suffer.
|
||||||
|
|
||||||
By default, TaskChampion syncs to a "local server", as specified by the `server_dir` configuration parameter.
|
By default, TaskChampion syncs to a "local server", as specified by the `server_dir` configuration parameter.
|
||||||
It is possible to switch to a remote server later by setting `server_origin` and `server_client_id` appropriately.
|
Every replica sharing a task history should have precisely the same configuration for `server_origin`, `server_client_id`, and `encryption_secret`.
|
||||||
|
|
||||||
|
Synchronizing a new replica to an existing task history is easy: begin with an empty replica, configured for the remote server, and run `task sync`.
|
||||||
|
The replica will download the entire task history.
|
||||||
|
|
||||||
|
It is possible to switch a single replica to a remote server by simply configuring for the remote server and running `task sync`.
|
||||||
|
The replica will upload the entire task history to the server.
|
||||||
|
Once this is complete, additional replicas can be configured with the same settings in order to share the task history.
|
||||||
|
|
||||||
## `taskchampion-sync-server`
|
## `taskchampion-sync-server`
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ kv = {version = "^0.10.0", features = ["msgpack-value"]}
|
||||||
lmdb-rkv = {version = "^0.12.3"}
|
lmdb-rkv = {version = "^0.12.3"}
|
||||||
ureq = "^1.5.2"
|
ureq = "^1.5.2"
|
||||||
log = "^0.4.11"
|
log = "^0.4.11"
|
||||||
|
tindercrypt = { version = "^0.2.2", default-features = false }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "^0.9.4"
|
proptest = "^0.9.4"
|
||||||
|
|
|
@ -22,5 +22,9 @@ pub enum ServerConfig {
|
||||||
|
|
||||||
/// Client ID to identify this replica to the server
|
/// Client ID to identify this replica to the server
|
||||||
client_id: Uuid,
|
client_id: Uuid,
|
||||||
|
|
||||||
|
/// Private encryption secret used to encrypt all data sent to the server. This can
|
||||||
|
/// be any suitably un-guessable string of bytes.
|
||||||
|
encryption_secret: Vec<u8>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,8 +16,10 @@ pub use types::*;
|
||||||
pub fn from_config(config: ServerConfig) -> Fallible<Box<dyn Server>> {
|
pub fn from_config(config: ServerConfig) -> Fallible<Box<dyn Server>> {
|
||||||
Ok(match config {
|
Ok(match config {
|
||||||
ServerConfig::Local { server_dir } => Box::new(LocalServer::new(server_dir)?),
|
ServerConfig::Local { server_dir } => Box::new(LocalServer::new(server_dir)?),
|
||||||
ServerConfig::Remote { origin, client_id } => {
|
ServerConfig::Remote {
|
||||||
Box::new(RemoteServer::new(origin, client_id))
|
origin,
|
||||||
}
|
client_id,
|
||||||
|
encryption_secret,
|
||||||
|
} => Box::new(RemoteServer::new(origin, client_id, encryption_secret)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
131
taskchampion/src/server/remote/crypto.rs
Normal file
131
taskchampion/src/server/remote/crypto.rs
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
use crate::server::HistorySegment;
|
||||||
|
use failure::{format_err, Fallible};
|
||||||
|
use std::convert::TryFrom;
|
||||||
|
use std::io::Read;
|
||||||
|
use tindercrypt::cryptors::RingCryptor;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub(super) struct Secret(pub(super) Vec<u8>);
|
||||||
|
|
||||||
|
impl From<Vec<u8>> for Secret {
|
||||||
|
fn from(bytes: Vec<u8>) -> Self {
|
||||||
|
Self(bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<[u8]> for Secret {
|
||||||
|
fn as_ref(&self) -> &[u8] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cleartext payload containing a history segment.
|
||||||
|
pub(super) struct HistoryCleartext {
|
||||||
|
pub(super) parent_version_id: Uuid,
|
||||||
|
pub(super) history_segment: HistorySegment,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HistoryCleartext {
|
||||||
|
/// Seal the payload into its ciphertext
|
||||||
|
pub(super) fn seal(self, secret: &Secret) -> Fallible<HistoryCiphertext> {
|
||||||
|
let cryptor = RingCryptor::new().with_aad(self.parent_version_id.as_bytes());
|
||||||
|
let ciphertext = cryptor.seal_with_passphrase(secret.as_ref(), &self.history_segment)?;
|
||||||
|
Ok(HistoryCiphertext(ciphertext))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ecrypted payload containing a history segment
|
||||||
|
pub(super) struct HistoryCiphertext(pub(super) Vec<u8>);
|
||||||
|
|
||||||
|
impl HistoryCiphertext {
|
||||||
|
pub(super) fn open(
|
||||||
|
self,
|
||||||
|
secret: &Secret,
|
||||||
|
parent_version_id: Uuid,
|
||||||
|
) -> Fallible<HistoryCleartext> {
|
||||||
|
let cryptor = RingCryptor::new().with_aad(parent_version_id.as_bytes());
|
||||||
|
let plaintext = cryptor.open(secret.as_ref(), &self.0)?;
|
||||||
|
|
||||||
|
Ok(HistoryCleartext {
|
||||||
|
parent_version_id,
|
||||||
|
history_segment: plaintext,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<ureq::Response> for HistoryCiphertext {
|
||||||
|
type Error = failure::Error;
|
||||||
|
|
||||||
|
fn try_from(resp: ureq::Response) -> Result<HistoryCiphertext, failure::Error> {
|
||||||
|
if let Some("application/vnd.taskchampion.history-segment") = resp.header("Content-Type") {
|
||||||
|
let mut reader = resp.into_reader();
|
||||||
|
let mut bytes = vec![];
|
||||||
|
reader.read_to_end(&mut bytes)?;
|
||||||
|
Ok(Self(bytes))
|
||||||
|
} else {
|
||||||
|
Err(format_err!("Response did not have expected content-type"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<[u8]> for HistoryCiphertext {
|
||||||
|
fn as_ref(&self) -> &[u8] {
|
||||||
|
self.0.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip() {
|
||||||
|
let parent_version_id = Uuid::new_v4();
|
||||||
|
let history_segment = b"HISTORY REPEATS ITSELF".to_vec();
|
||||||
|
let secret = Secret(b"SEKRIT".to_vec());
|
||||||
|
|
||||||
|
let history_cleartext = HistoryCleartext {
|
||||||
|
parent_version_id,
|
||||||
|
history_segment: history_segment.clone(),
|
||||||
|
};
|
||||||
|
let history_ciphertext = history_cleartext.seal(&secret).unwrap();
|
||||||
|
let history_cleartext = history_ciphertext.open(&secret, parent_version_id).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(history_cleartext.history_segment, history_segment);
|
||||||
|
assert_eq!(history_cleartext.parent_version_id, parent_version_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_bad_key() {
|
||||||
|
let parent_version_id = Uuid::new_v4();
|
||||||
|
let history_segment = b"HISTORY REPEATS ITSELF".to_vec();
|
||||||
|
let secret = Secret(b"SEKRIT".to_vec());
|
||||||
|
|
||||||
|
let history_cleartext = HistoryCleartext {
|
||||||
|
parent_version_id,
|
||||||
|
history_segment: history_segment.clone(),
|
||||||
|
};
|
||||||
|
let history_ciphertext = history_cleartext.seal(&secret).unwrap();
|
||||||
|
|
||||||
|
let secret = Secret(b"BADSEKRIT".to_vec());
|
||||||
|
assert!(history_ciphertext.open(&secret, parent_version_id).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_bad_pvid() {
|
||||||
|
let parent_version_id = Uuid::new_v4();
|
||||||
|
let history_segment = b"HISTORY REPEATS ITSELF".to_vec();
|
||||||
|
let secret = Secret(b"SEKRIT".to_vec());
|
||||||
|
|
||||||
|
let history_cleartext = HistoryCleartext {
|
||||||
|
parent_version_id,
|
||||||
|
history_segment: history_segment.clone(),
|
||||||
|
};
|
||||||
|
let history_ciphertext = history_cleartext.seal(&secret).unwrap();
|
||||||
|
|
||||||
|
let bad_parent_version_id = Uuid::new_v4();
|
||||||
|
assert!(history_ciphertext
|
||||||
|
.open(&secret, bad_parent_version_id)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,11 +1,15 @@
|
||||||
use crate::server::{AddVersionResult, GetVersionResult, HistorySegment, Server, VersionId};
|
use crate::server::{AddVersionResult, GetVersionResult, HistorySegment, Server, VersionId};
|
||||||
use failure::{format_err, Fallible};
|
use failure::{format_err, Fallible};
|
||||||
use std::io::Read;
|
use std::convert::TryInto;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
mod crypto;
|
||||||
|
use crypto::{HistoryCiphertext, HistoryCleartext, Secret};
|
||||||
|
|
||||||
pub struct RemoteServer {
|
pub struct RemoteServer {
|
||||||
origin: String,
|
origin: String,
|
||||||
client_id: Uuid,
|
client_id: Uuid,
|
||||||
|
encryption_secret: Secret,
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,10 +20,11 @@ impl RemoteServer {
|
||||||
/// without a trailing slash, such as `https://tcsync.example.com`. Pass a client_id to
|
/// without a trailing slash, such as `https://tcsync.example.com`. Pass a client_id to
|
||||||
/// identify this client to the server. Multiple replicas synchronizing the same task history
|
/// identify this client to the server. Multiple replicas synchronizing the same task history
|
||||||
/// should use the same client_id.
|
/// should use the same client_id.
|
||||||
pub fn new(origin: String, client_id: Uuid) -> RemoteServer {
|
pub fn new(origin: String, client_id: Uuid, encryption_secret: Vec<u8>) -> RemoteServer {
|
||||||
RemoteServer {
|
RemoteServer {
|
||||||
origin,
|
origin,
|
||||||
client_id,
|
client_id,
|
||||||
|
encryption_secret: encryption_secret.into(),
|
||||||
agent: ureq::agent(),
|
agent: ureq::agent(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -45,18 +50,6 @@ fn get_uuid_header(resp: &ureq::Response, name: &str) -> Fallible<Uuid> {
|
||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the body of a request as a HistorySegment
|
|
||||||
fn into_body(resp: ureq::Response) -> Fallible<HistorySegment> {
|
|
||||||
if let Some("application/vnd.taskchampion.history-segment") = resp.header("Content-Type") {
|
|
||||||
let mut reader = resp.into_reader();
|
|
||||||
let mut bytes = vec![];
|
|
||||||
reader.read_to_end(&mut bytes)?;
|
|
||||||
Ok(bytes)
|
|
||||||
} else {
|
|
||||||
Err(format_err!("Response did not have expected content-type"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Server for RemoteServer {
|
impl Server for RemoteServer {
|
||||||
fn add_version(
|
fn add_version(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
@ -67,6 +60,11 @@ impl Server for RemoteServer {
|
||||||
"{}/client/{}/add-version/{}",
|
"{}/client/{}/add-version/{}",
|
||||||
self.origin, self.client_id, parent_version_id
|
self.origin, self.client_id, parent_version_id
|
||||||
);
|
);
|
||||||
|
let history_cleartext = HistoryCleartext {
|
||||||
|
parent_version_id,
|
||||||
|
history_segment,
|
||||||
|
};
|
||||||
|
let history_ciphertext = history_cleartext.seal(&self.encryption_secret)?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.agent
|
.agent
|
||||||
.post(&url)
|
.post(&url)
|
||||||
|
@ -76,7 +74,7 @@ impl Server for RemoteServer {
|
||||||
"Content-Type",
|
"Content-Type",
|
||||||
"application/vnd.taskchampion.history-segment",
|
"application/vnd.taskchampion.history-segment",
|
||||||
)
|
)
|
||||||
.send_bytes(&history_segment);
|
.send_bytes(history_ciphertext.as_ref());
|
||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
let version_id = get_uuid_header(&resp, "X-Version-Id")?;
|
let version_id = get_uuid_header(&resp, "X-Version-Id")?;
|
||||||
Ok(AddVersionResult::Ok(version_id))
|
Ok(AddVersionResult::Ok(version_id))
|
||||||
|
@ -101,10 +99,16 @@ impl Server for RemoteServer {
|
||||||
.call();
|
.call();
|
||||||
|
|
||||||
if resp.ok() {
|
if resp.ok() {
|
||||||
|
let parent_version_id = get_uuid_header(&resp, "X-Parent-Version-Id")?;
|
||||||
|
let version_id = get_uuid_header(&resp, "X-Version-Id")?;
|
||||||
|
let history_ciphertext: HistoryCiphertext = resp.try_into()?;
|
||||||
|
let history_segment = history_ciphertext
|
||||||
|
.open(&self.encryption_secret, parent_version_id)?
|
||||||
|
.history_segment;
|
||||||
Ok(GetVersionResult::Version {
|
Ok(GetVersionResult::Version {
|
||||||
version_id: get_uuid_header(&resp, "X-Version-Id")?,
|
version_id,
|
||||||
parent_version_id: get_uuid_header(&resp, "X-Parent-Version-Id")?,
|
parent_version_id,
|
||||||
history_segment: into_body(resp)?,
|
history_segment,
|
||||||
})
|
})
|
||||||
} else if resp.status() == 404 {
|
} else if resp.status() == 404 {
|
||||||
Ok(GetVersionResult::NoSuchVersion)
|
Ok(GetVersionResult::NoSuchVersion)
|
Loading…
Add table
Add a link
Reference in a new issue