Merge pull request #301 from djmitche/issue299

Drop tindercrypt, document encryption
This commit is contained in:
Dustin J. Mitchell 2021-10-26 22:05:59 -04:00 committed by GitHub
commit 7c8c85f27f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 558 additions and 165 deletions

View file

@ -19,11 +19,12 @@ anyhow = "1.0"
thiserror = "1.0"
ureq = "^2.1.0"
log = "^0.4.14"
tindercrypt = { version = "^0.2.2", default-features = false }
rusqlite = { version = "0.25", features = ["bundled"] }
strum = "0.21"
strum_macros = "0.21"
flate2 = "1"
byteorder = "1.0"
ring = "0.16"
[dev-dependencies]
proptest = "^1.0.0"

View file

@ -33,7 +33,7 @@ impl ServerConfig {
origin,
client_key,
encryption_secret,
} => Box::new(RemoteServer::new(origin, client_key, encryption_secret)),
} => Box::new(RemoteServer::new(origin, client_key, encryption_secret)?),
})
}
}

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

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

View file

@ -12,6 +12,7 @@ However, users who wish to implement their own server interfaces can implement t
pub(crate) mod test;
mod config;
mod crypto;
mod local;
mod remote;
mod types;

View file

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

View file

@ -5,13 +5,12 @@ use crate::server::{
use std::time::Duration;
use uuid::Uuid;
mod crypto;
use crypto::{Ciphertext, Cleartext, Secret};
use super::crypto::{Cryptor, Sealed, Secret, Unsealed};
pub struct RemoteServer {
origin: String,
client_key: Uuid,
encryption_secret: Secret,
cryptor: Cryptor,
agent: ureq::Agent,
}
@ -28,16 +27,20 @@ impl RemoteServer {
/// 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
/// should use the same client_key.
pub fn new(origin: String, client_key: Uuid, encryption_secret: Vec<u8>) -> RemoteServer {
RemoteServer {
pub fn new(
origin: String,
client_key: Uuid,
encryption_secret: Vec<u8>,
) -> anyhow::Result<RemoteServer> {
Ok(RemoteServer {
origin,
client_key,
encryption_secret: encryption_secret.into(),
cryptor: Cryptor::new(client_key, &Secret(encryption_secret.to_vec()))?,
agent: ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(10))
.timeout_read(Duration::from_secs(60))
.build(),
}
})
}
}
@ -73,17 +76,17 @@ impl Server for RemoteServer {
"{}/v1/client/add-version/{}",
self.origin, parent_version_id
);
let cleartext = Cleartext {
let unsealed = Unsealed {
version_id: parent_version_id,
payload: history_segment,
};
let ciphertext = cleartext.seal(&self.encryption_secret)?;
let sealed = self.cryptor.seal(unsealed)?;
match self
.agent
.post(&url)
.set("Content-Type", HISTORY_SEGMENT_CONTENT_TYPE)
.set("X-Client-Key", &self.client_key.to_string())
.send_bytes(ciphertext.as_ref())
.send_bytes(sealed.as_ref())
{
Ok(resp) => {
let version_id = get_uuid_header(&resp, "X-Version-Id")?;
@ -120,10 +123,9 @@ impl Server for RemoteServer {
Ok(resp) => {
let parent_version_id = get_uuid_header(&resp, "X-Parent-Version-Id")?;
let version_id = get_uuid_header(&resp, "X-Version-Id")?;
let ciphertext = Ciphertext::from_resp(resp, HISTORY_SEGMENT_CONTENT_TYPE)?;
let history_segment = ciphertext
.open(&self.encryption_secret, parent_version_id)?
.payload;
let sealed =
Sealed::from_resp(resp, parent_version_id, HISTORY_SEGMENT_CONTENT_TYPE)?;
let history_segment = self.cryptor.unseal(sealed)?.payload;
Ok(GetVersionResult::Version {
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<()> {
let url = format!("{}/v1/client/add-snapshot/{}", self.origin, version_id);
let cleartext = Cleartext {
let unsealed = Unsealed {
version_id,
payload: snapshot,
};
let ciphertext = cleartext.seal(&self.encryption_secret)?;
let sealed = self.cryptor.seal(unsealed)?;
Ok(self
.agent
.post(&url)
.set("Content-Type", SNAPSHOT_CONTENT_TYPE)
.set("X-Client-Key", &self.client_key.to_string())
.send_bytes(ciphertext.as_ref())
.send_bytes(sealed.as_ref())
.map(|_| ())?)
}

View file

@ -0,0 +1,2 @@
$á­
†—Õ^~B>n)ji†¯1—î9™|µœÓ~

View file

@ -0,0 +1 @@
<01>ΝA4φ―Γθ t;Δτ υηp¦Ο¦x^Αύreό…<CF8C>JΤ¤<CEA4>

View file

@ -0,0 +1 @@
/}åd E°‡dIcÁXéè-‡!V°Û%è4îáòd]³ÃÇ}

View file

@ -0,0 +1 @@
lΰζδa|ο@Ο<>S_¬…γzέV9£q¦Ρ…‘)+¦

View file

@ -0,0 +1 @@
c╙╤TH╗Гp>╔╚Ф╨╕m4О╧к~в1╣0P░IЖ╢W╒

View file

@ -0,0 +1,2 @@
B∙
Ат-в3%╕jё,*ъ╨7Й╘√QьKЗO╕°FPZщ

View file

@ -0,0 +1 @@
pÑ¿µÒŸ½V²ûÝäToë"}cT·äY7Æ ˆÀ@ÙdLTý`Ò