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:
Dustin J. Mitchell 2020-12-26 16:37:31 +00:00
parent 6b70b47aa0
commit a8d45c67c6
8 changed files with 206 additions and 26 deletions

19
Cargo.lock generated
View file

@ -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"

View file

@ -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();

View file

@ -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`

View file

@ -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"

View file

@ -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>,
},
}

View file

@ -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)),
})
}

View 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());
}
}

View file

@ -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)