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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protobuf"
|
||||
version = "2.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da78e04bc0e40f36df43ecc6575e4f4b180e8156c4efd73f13d5619479b05696"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "1.5.4"
|
||||
|
@ -2255,6 +2261,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"tempdir",
|
||||
"tindercrypt",
|
||||
"ureq",
|
||||
"uuid",
|
||||
]
|
||||
|
@ -2456,6 +2463,18 @@ dependencies = [
|
|||
"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]]
|
||||
name = "tinyvec"
|
||||
version = "1.1.0"
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use crate::argparse::{Command, Subcommand};
|
||||
use config::Config;
|
||||
use failure::Fallible;
|
||||
use failure::{format_err, Fallible};
|
||||
use taskchampion::{server, Replica, ReplicaConfig, ServerConfig, Uuid};
|
||||
use termcolor::{ColorChoice, StandardStream};
|
||||
|
||||
|
@ -113,12 +113,16 @@ fn get_server(settings: &Config) -> Fallible<Box<dyn server::Server>> {
|
|||
settings.get_str("server_origin"),
|
||||
) {
|
||||
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!("Sync client ID: {}", client_id);
|
||||
Ok(server::from_config(ServerConfig::Remote {
|
||||
origin,
|
||||
client_id,
|
||||
encryption_secret: encryption_secret.as_bytes().to_vec(),
|
||||
})?)
|
||||
} else {
|
||||
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.
|
||||
This is only used if `server_origin` or `server_client_id` are not set.
|
||||
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`.
|
||||
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)
|
||||
|
@ -34,16 +39,26 @@ The following configuration parameters are available:
|
|||
|
||||
### 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`.
|
||||
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.
|
||||
|
||||
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`
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ kv = {version = "^0.10.0", features = ["msgpack-value"]}
|
|||
lmdb-rkv = {version = "^0.12.3"}
|
||||
ureq = "^1.5.2"
|
||||
log = "^0.4.11"
|
||||
tindercrypt = { version = "^0.2.2", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "^0.9.4"
|
||||
|
|
|
@ -22,5 +22,9 @@ pub enum ServerConfig {
|
|||
|
||||
/// Client ID to identify this replica to the server
|
||||
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>> {
|
||||
Ok(match config {
|
||||
ServerConfig::Local { server_dir } => Box::new(LocalServer::new(server_dir)?),
|
||||
ServerConfig::Remote { origin, client_id } => {
|
||||
Box::new(RemoteServer::new(origin, client_id))
|
||||
}
|
||||
ServerConfig::Remote {
|
||||
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 failure::{format_err, Fallible};
|
||||
use std::io::Read;
|
||||
use std::convert::TryInto;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod crypto;
|
||||
use crypto::{HistoryCiphertext, HistoryCleartext, Secret};
|
||||
|
||||
pub struct RemoteServer {
|
||||
origin: String,
|
||||
client_id: Uuid,
|
||||
encryption_secret: Secret,
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
|
@ -16,10 +20,11 @@ impl RemoteServer {
|
|||
/// 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
|
||||
/// 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 {
|
||||
origin,
|
||||
client_id,
|
||||
encryption_secret: encryption_secret.into(),
|
||||
agent: ureq::agent(),
|
||||
}
|
||||
}
|
||||
|
@ -45,18 +50,6 @@ fn get_uuid_header(resp: &ureq::Response, name: &str) -> Fallible<Uuid> {
|
|||
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 {
|
||||
fn add_version(
|
||||
&mut self,
|
||||
|
@ -67,6 +60,11 @@ impl Server for RemoteServer {
|
|||
"{}/client/{}/add-version/{}",
|
||||
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
|
||||
.agent
|
||||
.post(&url)
|
||||
|
@ -76,7 +74,7 @@ impl Server for RemoteServer {
|
|||
"Content-Type",
|
||||
"application/vnd.taskchampion.history-segment",
|
||||
)
|
||||
.send_bytes(&history_segment);
|
||||
.send_bytes(history_ciphertext.as_ref());
|
||||
if resp.ok() {
|
||||
let version_id = get_uuid_header(&resp, "X-Version-Id")?;
|
||||
Ok(AddVersionResult::Ok(version_id))
|
||||
|
@ -101,10 +99,16 @@ impl Server for RemoteServer {
|
|||
.call();
|
||||
|
||||
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 {
|
||||
version_id: get_uuid_header(&resp, "X-Version-Id")?,
|
||||
parent_version_id: get_uuid_header(&resp, "X-Parent-Version-Id")?,
|
||||
history_segment: into_body(resp)?,
|
||||
version_id,
|
||||
parent_version_id,
|
||||
history_segment,
|
||||
})
|
||||
} else if resp.status() == 404 {
|
||||
Ok(GetVersionResult::NoSuchVersion)
|
Loading…
Add table
Add a link
Reference in a new issue