mirror of
https://github.com/GothenburgBitFactory/taskwarrior.git
synced 2025-06-26 10:54:26 +02:00
Merge pull request #301 from djmitche/issue299
Drop tindercrypt, document encryption
This commit is contained in:
commit
7c8c85f27f
17 changed files with 558 additions and 165 deletions
1
.changelogs/2021-10-16-issue299.md
Normal file
1
.changelogs/2021-10-16-issue299.md
Normal file
|
@ -0,0 +1 @@
|
||||||
|
- The encryption format used for synchronization has changed incompatibly
|
21
Cargo.lock
generated
21
Cargo.lock
generated
|
@ -2253,12 +2253,6 @@ dependencies = [
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protobuf"
|
|
||||||
version = "2.24.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "db50e77ae196458ccd3dc58a31ea1a90b0698ab1b7928d89f644c25d72070267"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pulldown-cmark"
|
name = "pulldown-cmark"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
|
@ -2963,11 +2957,13 @@ name = "taskchampion"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"byteorder",
|
||||||
"chrono",
|
"chrono",
|
||||||
"flate2",
|
"flate2",
|
||||||
"log",
|
"log",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
"proptest",
|
"proptest",
|
||||||
|
"ring",
|
||||||
"rstest",
|
"rstest",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
|
@ -2976,7 +2972,6 @@ dependencies = [
|
||||||
"strum_macros 0.21.1",
|
"strum_macros 0.21.1",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tindercrypt",
|
|
||||||
"ureq",
|
"ureq",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
@ -3185,18 +3180,6 @@ 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.2.0"
|
version = "1.2.0"
|
||||||
|
|
|
@ -42,7 +42,40 @@ The fourth invariant prevents the server from discarding versions newer than the
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
|
|
||||||
TBD (#299)
|
The client configuration includes an encryption secret of arbitrary length and a clientId to identify itself.
|
||||||
|
This section describes how that information is used to encrypt and decrypt data sent to the server (versions and snapshots).
|
||||||
|
|
||||||
|
#### Key Derivation
|
||||||
|
|
||||||
|
The client derives the 32-byte encryption key from the configured encryption secret using PBKDF2 with HMAC-SHA256 and 100,000 iterations.
|
||||||
|
The salt is the SHA256 hash of the 16-byte form of the client key.
|
||||||
|
|
||||||
|
#### Encryption
|
||||||
|
|
||||||
|
The client uses [AEAD](https://commondatastorage.googleapis.com/chromium-boringssl-docs/aead.h.html), with algorithm CHACHA20_POLY1305.
|
||||||
|
The client should generate a random nonce, noting that AEAD is _not secure_ if a nonce is used repeatedly for the same key.
|
||||||
|
|
||||||
|
AEAD supports additional authenticated data (AAD) which must be provided for both open and seal operations.
|
||||||
|
In this protocol, the AAD is always 17 bytes of the form:
|
||||||
|
* `app_id` (byte) - always 1
|
||||||
|
* `version_id` (16 bytes) - 16-byte form of the version ID associated with this data
|
||||||
|
* for versions (AddVersion, GetChildVersion), the _parent_ version_id
|
||||||
|
* for snapshots (AddSnapshot, GetSnapshot), the snapshot version_id
|
||||||
|
|
||||||
|
The `app_id` field is for future expansion to handle other, non-task data using this protocol.
|
||||||
|
Including it in the AAD ensures that such data cannot be confused with task data.
|
||||||
|
|
||||||
|
Although the AEAD specification distinguishes ciphertext and tags, for purposes of this specification they are considered concatenated into a single bytestring as in BoringSSL's `EVP_AEAD_CTX_seal`.
|
||||||
|
|
||||||
|
#### Representation
|
||||||
|
|
||||||
|
The final byte-stream is comprised of the following structure:
|
||||||
|
|
||||||
|
* `version` (byte) - format version (always 1)
|
||||||
|
* `nonce` (12 bytes) - encryption nonce
|
||||||
|
* `ciphertext` (remaining bytes) - ciphertext from sealing operation
|
||||||
|
|
||||||
|
The `version` field identifies this data format, and future formats will have a value other than 1 in this position.
|
||||||
|
|
||||||
### Version
|
### Version
|
||||||
|
|
||||||
|
|
|
@ -19,11 +19,12 @@ anyhow = "1.0"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
ureq = "^2.1.0"
|
ureq = "^2.1.0"
|
||||||
log = "^0.4.14"
|
log = "^0.4.14"
|
||||||
tindercrypt = { version = "^0.2.2", default-features = false }
|
|
||||||
rusqlite = { version = "0.25", features = ["bundled"] }
|
rusqlite = { version = "0.25", features = ["bundled"] }
|
||||||
strum = "0.21"
|
strum = "0.21"
|
||||||
strum_macros = "0.21"
|
strum_macros = "0.21"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
|
byteorder = "1.0"
|
||||||
|
ring = "0.16"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "^1.0.0"
|
proptest = "^1.0.0"
|
||||||
|
|
|
@ -33,7 +33,7 @@ impl ServerConfig {
|
||||||
origin,
|
origin,
|
||||||
client_key,
|
client_key,
|
||||||
encryption_secret,
|
encryption_secret,
|
||||||
} => Box::new(RemoteServer::new(origin, client_key, encryption_secret)),
|
} => Box::new(RemoteServer::new(origin, client_key, encryption_secret)?),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
412
taskchampion/src/server/crypto.rs
Normal file
412
taskchampion/src/server/crypto.rs
Normal file
|
@ -0,0 +1,412 @@
|
||||||
|
/// This module implements the encryption specified in the sync-protocol
|
||||||
|
/// document.
|
||||||
|
use ring::{aead, digest, pbkdf2, rand, rand::SecureRandom};
|
||||||
|
use std::io::Read;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const PBKDF2_ITERATIONS: u32 = 100000;
|
||||||
|
const ENVELOPE_VERSION: u8 = 1;
|
||||||
|
const AAD_LEN: usize = 17;
|
||||||
|
const TASK_APP_ID: u8 = 1;
|
||||||
|
|
||||||
|
/// An Cryptor stores a secret and allows sealing and unsealing. It derives a key from the secret,
|
||||||
|
/// which takes a nontrivial amount of time, so it should be created once and re-used for the given
|
||||||
|
/// client_key.
|
||||||
|
pub(super) struct Cryptor {
|
||||||
|
key: aead::LessSafeKey,
|
||||||
|
rng: rand::SystemRandom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cryptor {
|
||||||
|
pub(super) fn new(client_key: Uuid, secret: &Secret) -> anyhow::Result<Self> {
|
||||||
|
Ok(Cryptor {
|
||||||
|
key: Self::derive_key(client_key, secret)?,
|
||||||
|
rng: rand::SystemRandom::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive a key as specified for version 1. Note that this may take 10s of ms.
|
||||||
|
fn derive_key(client_key: Uuid, secret: &Secret) -> anyhow::Result<aead::LessSafeKey> {
|
||||||
|
let salt = digest::digest(&digest::SHA256, client_key.as_bytes());
|
||||||
|
|
||||||
|
let mut key_bytes = vec![0u8; aead::CHACHA20_POLY1305.key_len()];
|
||||||
|
pbkdf2::derive(
|
||||||
|
pbkdf2::PBKDF2_HMAC_SHA256,
|
||||||
|
std::num::NonZeroU32::new(PBKDF2_ITERATIONS).unwrap(),
|
||||||
|
salt.as_ref(),
|
||||||
|
secret.as_ref(),
|
||||||
|
&mut key_bytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
let unbound_key = aead::UnboundKey::new(&aead::CHACHA20_POLY1305, &key_bytes)
|
||||||
|
.map_err(|_| anyhow::anyhow!("error while creating AEAD key"))?;
|
||||||
|
Ok(aead::LessSafeKey::new(unbound_key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt the given payload.
|
||||||
|
pub(super) fn seal(&self, payload: Unsealed) -> anyhow::Result<Sealed> {
|
||||||
|
let Unsealed {
|
||||||
|
version_id,
|
||||||
|
mut payload,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
let mut nonce_buf = [0u8; aead::NONCE_LEN];
|
||||||
|
self.rng
|
||||||
|
.fill(&mut nonce_buf)
|
||||||
|
.map_err(|_| anyhow::anyhow!("error generating random nonce"))?;
|
||||||
|
let nonce = aead::Nonce::assume_unique_for_key(nonce_buf);
|
||||||
|
|
||||||
|
let aad = self.make_aad(version_id);
|
||||||
|
|
||||||
|
let tag = self
|
||||||
|
.key
|
||||||
|
.seal_in_place_separate_tag(nonce, aad, &mut payload)
|
||||||
|
.map_err(|_| anyhow::anyhow!("error while sealing"))?;
|
||||||
|
payload.extend_from_slice(tag.as_ref());
|
||||||
|
|
||||||
|
let env = Envelope {
|
||||||
|
nonce: &nonce_buf,
|
||||||
|
payload: payload.as_ref(),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Sealed {
|
||||||
|
version_id,
|
||||||
|
payload: env.to_bytes(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt the given payload, verifying it was created for the given version_id
|
||||||
|
pub(super) fn unseal(&self, payload: Sealed) -> anyhow::Result<Unsealed> {
|
||||||
|
let Sealed {
|
||||||
|
version_id,
|
||||||
|
payload,
|
||||||
|
} = payload;
|
||||||
|
|
||||||
|
let env = Envelope::from_bytes(&payload)?;
|
||||||
|
|
||||||
|
let mut nonce = [0u8; aead::NONCE_LEN];
|
||||||
|
nonce.copy_from_slice(env.nonce);
|
||||||
|
let nonce = aead::Nonce::assume_unique_for_key(nonce);
|
||||||
|
let aad = self.make_aad(version_id);
|
||||||
|
|
||||||
|
let mut payload = env.payload.to_vec();
|
||||||
|
let plaintext = self
|
||||||
|
.key
|
||||||
|
.open_in_place(nonce, aad, payload.as_mut())
|
||||||
|
.map_err(|_| anyhow::anyhow!("error while creating AEAD key"))?;
|
||||||
|
|
||||||
|
Ok(Unsealed {
|
||||||
|
version_id,
|
||||||
|
payload: plaintext.to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_aad(&self, version_id: Uuid) -> aead::Aad<[u8; AAD_LEN]> {
|
||||||
|
let mut aad = [0u8; AAD_LEN];
|
||||||
|
aad[0] = TASK_APP_ID;
|
||||||
|
aad[1..].copy_from_slice(version_id.as_bytes());
|
||||||
|
aead::Aad::from(aad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secret represents a secret key as used for encryption and decryption.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envelope for the data stored on the server, containing the information
|
||||||
|
/// required to decrypt.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
struct Envelope<'a> {
|
||||||
|
nonce: &'a [u8],
|
||||||
|
payload: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Envelope<'a> {
|
||||||
|
fn from_bytes(buf: &'a [u8]) -> anyhow::Result<Envelope<'a>> {
|
||||||
|
if buf.len() <= 1 + aead::NONCE_LEN {
|
||||||
|
anyhow::bail!("envelope is too small");
|
||||||
|
}
|
||||||
|
|
||||||
|
let version = buf[0];
|
||||||
|
if version != ENVELOPE_VERSION {
|
||||||
|
anyhow::bail!("unrecognized encryption envelope version {}", version);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Envelope {
|
||||||
|
nonce: &buf[1..1 + aead::NONCE_LEN],
|
||||||
|
payload: &buf[1 + aead::NONCE_LEN..],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_bytes(&self) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::with_capacity(1 + self.nonce.len() + self.payload.len());
|
||||||
|
|
||||||
|
buf.push(ENVELOPE_VERSION);
|
||||||
|
buf.extend_from_slice(self.nonce);
|
||||||
|
buf.extend_from_slice(self.payload);
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A unsealed payload with an attached version_id. The version_id is used to
|
||||||
|
/// validate the context of the payload on unsealing.
|
||||||
|
pub(super) struct Unsealed {
|
||||||
|
pub(super) version_id: Uuid,
|
||||||
|
pub(super) payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An encrypted payload
|
||||||
|
pub(super) struct Sealed {
|
||||||
|
pub(super) version_id: Uuid,
|
||||||
|
pub(super) payload: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Sealed {
|
||||||
|
pub(super) fn from_resp(
|
||||||
|
resp: ureq::Response,
|
||||||
|
version_id: Uuid,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<Sealed, anyhow::Error> {
|
||||||
|
if resp.header("Content-Type") == Some(content_type) {
|
||||||
|
let mut reader = resp.into_reader();
|
||||||
|
let mut payload = vec![];
|
||||||
|
reader.read_to_end(&mut payload)?;
|
||||||
|
Ok(Self {
|
||||||
|
version_id,
|
||||||
|
payload,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"Response did not have expected content-type"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<[u8]> for Sealed {
|
||||||
|
fn as_ref(&self) -> &[u8] {
|
||||||
|
self.payload.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_round_trip() {
|
||||||
|
let env = Envelope {
|
||||||
|
nonce: &[2; 12],
|
||||||
|
payload: b"HELLO",
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = env.to_bytes();
|
||||||
|
let env2 = Envelope::from_bytes(&bytes).unwrap();
|
||||||
|
assert_eq!(env, env2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_bad_version() {
|
||||||
|
let env = Envelope {
|
||||||
|
nonce: &[2; 12],
|
||||||
|
payload: b"HELLO",
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut bytes = env.to_bytes();
|
||||||
|
bytes[0] = 99;
|
||||||
|
assert!(Envelope::from_bytes(&bytes).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_too_short() {
|
||||||
|
let env = Envelope {
|
||||||
|
nonce: &[2; 12],
|
||||||
|
payload: b"HELLO",
|
||||||
|
};
|
||||||
|
|
||||||
|
let bytes = env.to_bytes();
|
||||||
|
let bytes = &bytes[..10];
|
||||||
|
assert!(Envelope::from_bytes(bytes).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip() {
|
||||||
|
let version_id = Uuid::new_v4();
|
||||||
|
let payload = b"HISTORY REPEATS ITSELF".to_vec();
|
||||||
|
|
||||||
|
let secret = Secret(b"SEKRIT".to_vec());
|
||||||
|
let cryptor = Cryptor::new(Uuid::new_v4(), &secret).unwrap();
|
||||||
|
|
||||||
|
let unsealed = Unsealed {
|
||||||
|
version_id,
|
||||||
|
payload: payload.clone(),
|
||||||
|
};
|
||||||
|
let sealed = cryptor.seal(unsealed).unwrap();
|
||||||
|
let unsealed = cryptor.unseal(sealed).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(unsealed.payload, payload);
|
||||||
|
assert_eq!(unsealed.version_id, version_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_bad_key() {
|
||||||
|
let version_id = Uuid::new_v4();
|
||||||
|
let payload = b"HISTORY REPEATS ITSELF".to_vec();
|
||||||
|
let client_key = Uuid::new_v4();
|
||||||
|
|
||||||
|
let secret = Secret(b"SEKRIT".to_vec());
|
||||||
|
let cryptor = Cryptor::new(client_key, &secret).unwrap();
|
||||||
|
|
||||||
|
let unsealed = Unsealed {
|
||||||
|
version_id,
|
||||||
|
payload: payload.clone(),
|
||||||
|
};
|
||||||
|
let sealed = cryptor.seal(unsealed).unwrap();
|
||||||
|
|
||||||
|
let secret = Secret(b"DIFFERENT_SECRET".to_vec());
|
||||||
|
let cryptor = Cryptor::new(client_key, &secret).unwrap();
|
||||||
|
assert!(cryptor.unseal(sealed).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_bad_version() {
|
||||||
|
let version_id = Uuid::new_v4();
|
||||||
|
let payload = b"HISTORY REPEATS ITSELF".to_vec();
|
||||||
|
let client_key = Uuid::new_v4();
|
||||||
|
|
||||||
|
let secret = Secret(b"SEKRIT".to_vec());
|
||||||
|
let cryptor = Cryptor::new(client_key, &secret).unwrap();
|
||||||
|
|
||||||
|
let unsealed = Unsealed {
|
||||||
|
version_id,
|
||||||
|
payload: payload.clone(),
|
||||||
|
};
|
||||||
|
let mut sealed = cryptor.seal(unsealed).unwrap();
|
||||||
|
sealed.version_id = Uuid::new_v4(); // change the version_id
|
||||||
|
assert!(cryptor.unseal(sealed).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_bad_client_key() {
|
||||||
|
let version_id = Uuid::new_v4();
|
||||||
|
let payload = b"HISTORY REPEATS ITSELF".to_vec();
|
||||||
|
let client_key = Uuid::new_v4();
|
||||||
|
|
||||||
|
let secret = Secret(b"SEKRIT".to_vec());
|
||||||
|
let cryptor = Cryptor::new(client_key, &secret).unwrap();
|
||||||
|
|
||||||
|
let unsealed = Unsealed {
|
||||||
|
version_id,
|
||||||
|
payload: payload.clone(),
|
||||||
|
};
|
||||||
|
let sealed = cryptor.seal(unsealed).unwrap();
|
||||||
|
|
||||||
|
let client_key = Uuid::new_v4();
|
||||||
|
let cryptor = Cryptor::new(client_key, &secret).unwrap();
|
||||||
|
assert!(cryptor.unseal(sealed).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
mod externally_valid {
|
||||||
|
// validate data generated by generate-test-data.py. The intent is to
|
||||||
|
// validate that this format matches the specification by implementing
|
||||||
|
// the specification in a second language
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
/// The values in generate-test-data.py
|
||||||
|
fn defaults() -> (Uuid, Uuid, Vec<u8>) {
|
||||||
|
(
|
||||||
|
Uuid::parse_str("b0517957-f912-4d49-8330-f612e73030c4").unwrap(),
|
||||||
|
Uuid::parse_str("0666d464-418a-4a08-ad53-6f15c78270cd").unwrap(),
|
||||||
|
b"b4a4e6b7b811eda1dc1a2693ded".to_vec(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn good() {
|
||||||
|
let (version_id, client_key, encryption_secret) = defaults();
|
||||||
|
let sealed = Sealed {
|
||||||
|
version_id,
|
||||||
|
payload: include_bytes!("test-good.data").to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap();
|
||||||
|
let unsealed = cryptor.unseal(sealed).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(unsealed.payload, b"SUCCESS");
|
||||||
|
assert_eq!(unsealed.version_id, version_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_version_id() {
|
||||||
|
let (version_id, client_key, encryption_secret) = defaults();
|
||||||
|
let sealed = Sealed {
|
||||||
|
version_id,
|
||||||
|
payload: include_bytes!("test-bad-version-id.data").to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap();
|
||||||
|
assert!(cryptor.unseal(sealed).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_client_key() {
|
||||||
|
let (version_id, client_key, encryption_secret) = defaults();
|
||||||
|
let sealed = Sealed {
|
||||||
|
version_id,
|
||||||
|
payload: include_bytes!("test-bad-client-key.data").to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap();
|
||||||
|
assert!(cryptor.unseal(sealed).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_secret() {
|
||||||
|
let (version_id, client_key, encryption_secret) = defaults();
|
||||||
|
let sealed = Sealed {
|
||||||
|
version_id,
|
||||||
|
payload: include_bytes!("test-bad-secret.data").to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap();
|
||||||
|
assert!(cryptor.unseal(sealed).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_version() {
|
||||||
|
let (version_id, client_key, encryption_secret) = defaults();
|
||||||
|
let sealed = Sealed {
|
||||||
|
version_id,
|
||||||
|
payload: include_bytes!("test-bad-version.data").to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap();
|
||||||
|
assert!(cryptor.unseal(sealed).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bad_app_id() {
|
||||||
|
let (version_id, client_key, encryption_secret) = defaults();
|
||||||
|
let sealed = Sealed {
|
||||||
|
version_id,
|
||||||
|
payload: include_bytes!("test-bad-app-id.data").to_vec(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap();
|
||||||
|
assert!(cryptor.unseal(sealed).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
77
taskchampion/src/server/generate-test-data.py
Normal file
77
taskchampion/src/server/generate-test-data.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
# This file generates test-encrypted.data. To run it:
|
||||||
|
# - pip install cryptography pbkdf2
|
||||||
|
# - python taskchampion/src/server/generate-test-data.py taskchampion/src/server/
|
||||||
|
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import pbkdf2
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||||
|
|
||||||
|
# these values match values used in the rust tests
|
||||||
|
client_key = "0666d464-418a-4a08-ad53-6f15c78270cd"
|
||||||
|
encryption_secret = b"b4a4e6b7b811eda1dc1a2693ded"
|
||||||
|
version_id = "b0517957-f912-4d49-8330-f612e73030c4"
|
||||||
|
|
||||||
|
def gen(
|
||||||
|
version_id=version_id, client_key=client_key, encryption_secret=encryption_secret,
|
||||||
|
app_id=1, version=1):
|
||||||
|
# first, generate the encryption key
|
||||||
|
salt = hashlib.sha256(uuid.UUID(client_key).bytes).digest()
|
||||||
|
key = pbkdf2.PBKDF2(
|
||||||
|
encryption_secret,
|
||||||
|
salt,
|
||||||
|
digestmodule=hashlib.sha256,
|
||||||
|
iterations=100000,
|
||||||
|
).read(32)
|
||||||
|
|
||||||
|
# create a nonce
|
||||||
|
nonce = secrets.token_bytes(12)
|
||||||
|
|
||||||
|
assert len(b"\x01") == 1
|
||||||
|
# create the AAD
|
||||||
|
aad = b''.join([
|
||||||
|
bytes([app_id]),
|
||||||
|
uuid.UUID(version_id).bytes,
|
||||||
|
])
|
||||||
|
|
||||||
|
# encrypt using AEAD
|
||||||
|
chacha = ChaCha20Poly1305(key)
|
||||||
|
ciphertext = chacha.encrypt(nonce, b"SUCCESS", aad)
|
||||||
|
|
||||||
|
# create the envelope
|
||||||
|
envelope = b''.join([
|
||||||
|
bytes([version]),
|
||||||
|
nonce,
|
||||||
|
ciphertext,
|
||||||
|
])
|
||||||
|
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
dir = sys.argv[1]
|
||||||
|
|
||||||
|
with open(os.path.join(dir, 'test-good.data'), "wb") as f:
|
||||||
|
f.write(gen())
|
||||||
|
|
||||||
|
with open(os.path.join(dir, 'test-bad-version-id.data'), "wb") as f:
|
||||||
|
f.write(gen(version_id=uuid.uuid4().hex))
|
||||||
|
|
||||||
|
with open(os.path.join(dir, 'test-bad-client-key.data'), "wb") as f:
|
||||||
|
f.write(gen(client_key=uuid.uuid4().hex))
|
||||||
|
|
||||||
|
with open(os.path.join(dir, 'test-bad-secret.data'), "wb") as f:
|
||||||
|
f.write(gen(encryption_secret=b"xxxxxxxxxxxxxxxxxxxxx"))
|
||||||
|
|
||||||
|
with open(os.path.join(dir, 'test-bad-version.data'), "wb") as f:
|
||||||
|
f.write(gen(version=99))
|
||||||
|
|
||||||
|
with open(os.path.join(dir, 'test-bad-app-id.data'), "wb") as f:
|
||||||
|
f.write(gen(app_id=99))
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
|
@ -12,6 +12,7 @@ However, users who wish to implement their own server interfaces can implement t
|
||||||
pub(crate) mod test;
|
pub(crate) mod test;
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
|
mod crypto;
|
||||||
mod local;
|
mod local;
|
||||||
mod remote;
|
mod remote;
|
||||||
mod types;
|
mod types;
|
||||||
|
|
|
@ -1,126 +0,0 @@
|
||||||
use crate::server::HistorySegment;
|
|
||||||
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 with an attached version_id. The version_id is used to
|
|
||||||
/// validate the context of the payload.
|
|
||||||
pub(super) struct Cleartext {
|
|
||||||
pub(super) version_id: Uuid,
|
|
||||||
pub(super) payload: HistorySegment,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Cleartext {
|
|
||||||
/// Seal the payload into its ciphertext
|
|
||||||
pub(super) fn seal(self, secret: &Secret) -> anyhow::Result<Ciphertext> {
|
|
||||||
let cryptor = RingCryptor::new().with_aad(self.version_id.as_bytes());
|
|
||||||
let ciphertext = cryptor.seal_with_passphrase(secret.as_ref(), &self.payload)?;
|
|
||||||
Ok(Ciphertext(ciphertext))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An ecrypted payload
|
|
||||||
pub(super) struct Ciphertext(pub(super) Vec<u8>);
|
|
||||||
|
|
||||||
impl Ciphertext {
|
|
||||||
pub(super) fn from_resp(
|
|
||||||
resp: ureq::Response,
|
|
||||||
content_type: &str,
|
|
||||||
) -> Result<Ciphertext, anyhow::Error> {
|
|
||||||
if resp.header("Content-Type") == Some(content_type) {
|
|
||||||
let mut reader = resp.into_reader();
|
|
||||||
let mut bytes = vec![];
|
|
||||||
reader.read_to_end(&mut bytes)?;
|
|
||||||
Ok(Self(bytes))
|
|
||||||
} else {
|
|
||||||
Err(anyhow::anyhow!(
|
|
||||||
"Response did not have expected content-type"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn open(self, secret: &Secret, version_id: Uuid) -> anyhow::Result<Cleartext> {
|
|
||||||
let cryptor = RingCryptor::new().with_aad(version_id.as_bytes());
|
|
||||||
let plaintext = cryptor.open(secret.as_ref(), &self.0)?;
|
|
||||||
|
|
||||||
Ok(Cleartext {
|
|
||||||
version_id,
|
|
||||||
payload: plaintext,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<[u8]> for Ciphertext {
|
|
||||||
fn as_ref(&self) -> &[u8] {
|
|
||||||
self.0.as_ref()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod test {
|
|
||||||
use super::*;
|
|
||||||
use pretty_assertions::assert_eq;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip() {
|
|
||||||
let version_id = Uuid::new_v4();
|
|
||||||
let payload = b"HISTORY REPEATS ITSELF".to_vec();
|
|
||||||
let secret = Secret(b"SEKRIT".to_vec());
|
|
||||||
|
|
||||||
let cleartext = Cleartext {
|
|
||||||
version_id,
|
|
||||||
payload: payload.clone(),
|
|
||||||
};
|
|
||||||
let ciphertext = cleartext.seal(&secret).unwrap();
|
|
||||||
let cleartext = ciphertext.open(&secret, version_id).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(cleartext.payload, payload);
|
|
||||||
assert_eq!(cleartext.version_id, version_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_bad_key() {
|
|
||||||
let version_id = Uuid::new_v4();
|
|
||||||
let payload = b"HISTORY REPEATS ITSELF".to_vec();
|
|
||||||
let secret = Secret(b"SEKRIT".to_vec());
|
|
||||||
|
|
||||||
let cleartext = Cleartext {
|
|
||||||
version_id,
|
|
||||||
payload: payload.clone(),
|
|
||||||
};
|
|
||||||
let ciphertext = cleartext.seal(&secret).unwrap();
|
|
||||||
|
|
||||||
let secret = Secret(b"BADSEKRIT".to_vec());
|
|
||||||
assert!(ciphertext.open(&secret, version_id).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_bad_version() {
|
|
||||||
let version_id = Uuid::new_v4();
|
|
||||||
let payload = b"HISTORY REPEATS ITSELF".to_vec();
|
|
||||||
let secret = Secret(b"SEKRIT".to_vec());
|
|
||||||
|
|
||||||
let cleartext = Cleartext {
|
|
||||||
version_id,
|
|
||||||
payload: payload.clone(),
|
|
||||||
};
|
|
||||||
let ciphertext = cleartext.seal(&secret).unwrap();
|
|
||||||
|
|
||||||
let bad_version_id = Uuid::new_v4();
|
|
||||||
assert!(ciphertext.open(&secret, bad_version_id).is_err());
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -5,13 +5,12 @@ use crate::server::{
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
mod crypto;
|
use super::crypto::{Cryptor, Sealed, Secret, Unsealed};
|
||||||
use crypto::{Ciphertext, Cleartext, Secret};
|
|
||||||
|
|
||||||
pub struct RemoteServer {
|
pub struct RemoteServer {
|
||||||
origin: String,
|
origin: String,
|
||||||
client_key: Uuid,
|
client_key: Uuid,
|
||||||
encryption_secret: Secret,
|
cryptor: Cryptor,
|
||||||
agent: ureq::Agent,
|
agent: ureq::Agent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -28,16 +27,20 @@ impl RemoteServer {
|
||||||
/// without a trailing slash, such as `https://tcsync.example.com`. Pass a client_key to
|
/// without a trailing slash, such as `https://tcsync.example.com`. Pass a client_key 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_key.
|
/// should use the same client_key.
|
||||||
pub fn new(origin: String, client_key: Uuid, encryption_secret: Vec<u8>) -> RemoteServer {
|
pub fn new(
|
||||||
RemoteServer {
|
origin: String,
|
||||||
|
client_key: Uuid,
|
||||||
|
encryption_secret: Vec<u8>,
|
||||||
|
) -> anyhow::Result<RemoteServer> {
|
||||||
|
Ok(RemoteServer {
|
||||||
origin,
|
origin,
|
||||||
client_key,
|
client_key,
|
||||||
encryption_secret: encryption_secret.into(),
|
cryptor: Cryptor::new(client_key, &Secret(encryption_secret.to_vec()))?,
|
||||||
agent: ureq::AgentBuilder::new()
|
agent: ureq::AgentBuilder::new()
|
||||||
.timeout_connect(Duration::from_secs(10))
|
.timeout_connect(Duration::from_secs(10))
|
||||||
.timeout_read(Duration::from_secs(60))
|
.timeout_read(Duration::from_secs(60))
|
||||||
.build(),
|
.build(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,17 +76,17 @@ impl Server for RemoteServer {
|
||||||
"{}/v1/client/add-version/{}",
|
"{}/v1/client/add-version/{}",
|
||||||
self.origin, parent_version_id
|
self.origin, parent_version_id
|
||||||
);
|
);
|
||||||
let cleartext = Cleartext {
|
let unsealed = Unsealed {
|
||||||
version_id: parent_version_id,
|
version_id: parent_version_id,
|
||||||
payload: history_segment,
|
payload: history_segment,
|
||||||
};
|
};
|
||||||
let ciphertext = cleartext.seal(&self.encryption_secret)?;
|
let sealed = self.cryptor.seal(unsealed)?;
|
||||||
match self
|
match self
|
||||||
.agent
|
.agent
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.set("Content-Type", HISTORY_SEGMENT_CONTENT_TYPE)
|
.set("Content-Type", HISTORY_SEGMENT_CONTENT_TYPE)
|
||||||
.set("X-Client-Key", &self.client_key.to_string())
|
.set("X-Client-Key", &self.client_key.to_string())
|
||||||
.send_bytes(ciphertext.as_ref())
|
.send_bytes(sealed.as_ref())
|
||||||
{
|
{
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let version_id = get_uuid_header(&resp, "X-Version-Id")?;
|
let version_id = get_uuid_header(&resp, "X-Version-Id")?;
|
||||||
|
@ -120,10 +123,9 @@ impl Server for RemoteServer {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
let parent_version_id = get_uuid_header(&resp, "X-Parent-Version-Id")?;
|
let parent_version_id = get_uuid_header(&resp, "X-Parent-Version-Id")?;
|
||||||
let version_id = get_uuid_header(&resp, "X-Version-Id")?;
|
let version_id = get_uuid_header(&resp, "X-Version-Id")?;
|
||||||
let ciphertext = Ciphertext::from_resp(resp, HISTORY_SEGMENT_CONTENT_TYPE)?;
|
let sealed =
|
||||||
let history_segment = ciphertext
|
Sealed::from_resp(resp, parent_version_id, HISTORY_SEGMENT_CONTENT_TYPE)?;
|
||||||
.open(&self.encryption_secret, parent_version_id)?
|
let history_segment = self.cryptor.unseal(sealed)?.payload;
|
||||||
.payload;
|
|
||||||
Ok(GetVersionResult::Version {
|
Ok(GetVersionResult::Version {
|
||||||
version_id,
|
version_id,
|
||||||
parent_version_id,
|
parent_version_id,
|
||||||
|
@ -139,17 +141,17 @@ impl Server for RemoteServer {
|
||||||
|
|
||||||
fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> anyhow::Result<()> {
|
fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> anyhow::Result<()> {
|
||||||
let url = format!("{}/v1/client/add-snapshot/{}", self.origin, version_id);
|
let url = format!("{}/v1/client/add-snapshot/{}", self.origin, version_id);
|
||||||
let cleartext = Cleartext {
|
let unsealed = Unsealed {
|
||||||
version_id,
|
version_id,
|
||||||
payload: snapshot,
|
payload: snapshot,
|
||||||
};
|
};
|
||||||
let ciphertext = cleartext.seal(&self.encryption_secret)?;
|
let sealed = self.cryptor.seal(unsealed)?;
|
||||||
Ok(self
|
Ok(self
|
||||||
.agent
|
.agent
|
||||||
.post(&url)
|
.post(&url)
|
||||||
.set("Content-Type", SNAPSHOT_CONTENT_TYPE)
|
.set("Content-Type", SNAPSHOT_CONTENT_TYPE)
|
||||||
.set("X-Client-Key", &self.client_key.to_string())
|
.set("X-Client-Key", &self.client_key.to_string())
|
||||||
.send_bytes(ciphertext.as_ref())
|
.send_bytes(sealed.as_ref())
|
||||||
.map(|_| ())?)
|
.map(|_| ())?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
2
taskchampion/src/server/test-bad-app-id.data
Normal file
2
taskchampion/src/server/test-bad-app-id.data
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
#§$á
|
||||||
|
†—Õ^~B>n)j›i†¯1—î9™|µœÓ~
|
1
taskchampion/src/server/test-bad-client-key.data
Normal file
1
taskchampion/src/server/test-bad-client-key.data
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<01>ΝA4φ―Γθ
t;Δτ
υηp¦Ο¦x^Αύreό…<CF8C>JΤ¤<CEA4>
|
1
taskchampion/src/server/test-bad-secret.data
Normal file
1
taskchampion/src/server/test-bad-secret.data
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/}ådE°‡dIcÁXéè-‡!V°Û%è4îáòd]³ÃÇ}
|
1
taskchampion/src/server/test-bad-version-id.data
Normal file
1
taskchampion/src/server/test-bad-version-id.data
Normal file
|
@ -0,0 +1 @@
|
||||||
|
lΰζδa|‚ο@Ο<>S_‚¬…γzέV9£q¦Ρ…‘)+¦…
|
1
taskchampion/src/server/test-bad-version.data
Normal file
1
taskchampion/src/server/test-bad-version.data
Normal file
|
@ -0,0 +1 @@
|
||||||
|
c╙╤TH╗Гp>╔╚Ф╨╕m4О╧к~в1╣0P░IЖ╢W╒
|
2
taskchampion/src/server/test-bad-version_id.data
Normal file
2
taskchampion/src/server/test-bad-version_id.data
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
B∙
|
||||||
|
Ат-в3%╕jё,*ъ╨7Й╘√QьKЗO╕°FPZщ
|
1
taskchampion/src/server/test-good.data
Normal file
1
taskchampion/src/server/test-good.data
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pÑ¿µÒŸ½V²ûÝäToë"}cT·äY7Æ ˆÀ@ÙdLTý`Ò
|
Loading…
Add table
Add a link
Reference in a new issue