diff --git a/Cargo.toml b/Cargo.toml index 9002753..6267c5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +aes-gcm = "0.10" array-bytes = "6.2.0" bincode = "1" chrono = "0.4" @@ -13,6 +14,7 @@ clap = { version = "4.4.14", features = ["derive"] } curve25519-dalek = { version = "4", default-features = false, features = ["serde", "rand_core", "digest"] } ed25519-dalek = { version = "2", features = ["serde", "rand_core"] } futures = "0.3.30" +hkdf = "0.12" http = "1" http-body-util = "0.1" hyper = { version = "0.14.28", features = ["full"] } @@ -34,6 +36,7 @@ statrs = "0.16" time = "0.3.30" tokio = { version = "1", features = ["full"] } tokio-cron = "0.1.2" +x25519-dalek = { version = "2", features = ["serde", "static_secrets"] } [dev-dependencies] base64 = "0.21.7" diff --git a/src/bin/server.rs b/src/bin/server.rs index 0f8255f..5fc4bcf 100644 --- a/src/bin/server.rs +++ b/src/bin/server.rs @@ -90,6 +90,9 @@ async fn update_daily_info( confidence, ); report_blockages(&distributors, new_blockages).await; + + // Generate tomorrow's key if we don't already have it + new_negative_report_key(&db, get_date() + 1); } async fn run_updater(updater_tx: mpsc::Sender) { diff --git a/src/crypto.rs b/src/crypto.rs new file mode 100644 index 0000000..8bea524 --- /dev/null +++ b/src/crypto.rs @@ -0,0 +1,75 @@ +// Minimal implementation of ECIES with x25519_dalek +use aes_gcm::{ + aead::{Aead, AeadCore}, + Aes256Gcm, Key, KeyInit, Nonce, +}; +use hkdf::Hkdf; +use serde::{Deserialize, Serialize}; +use sha3::Sha3_256; +use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret}; + +const SALT_STRING: &str = "ECIES symmetric key for Troll Patrol"; + +#[derive(Serialize, Deserialize)] +pub struct EciesCiphertext { + pubkey: PublicKey, + nonce: Vec, + ct: Vec, +} + +impl EciesCiphertext { + pub fn encrypt(message: &[u8], receiver_pubkey: &PublicKey) -> Result { + // Compute shared secret based on new ephemeral ECDH key + let mut rng = rand::thread_rng(); + let secret = EphemeralSecret::random_from_rng(&mut rng); + let client_pubkey = PublicKey::from(&secret); + let shared_secret = secret.diffie_hellman(&receiver_pubkey); + + // Compute key + let hk = Hkdf::::new(None, shared_secret.as_bytes()); + let mut symmetric_key = [0u8; 32]; + if hk + .expand(SALT_STRING.as_bytes(), &mut symmetric_key) + .is_err() + { + return Err("Failed to encrypt".to_string()); + } + + // Encrypt with key + let key: Key = symmetric_key.into(); + let cipher = Aes256Gcm::new(&key); + let nonce = Aes256Gcm::generate_nonce(&mut rng); + match cipher.encrypt(&nonce, &*message) { + Ok(ct) => Ok(EciesCiphertext { + pubkey: client_pubkey, + nonce: nonce.to_vec(), + ct: ct, + }), + Err(_) => Err("Failed to encrypt".to_string()), + } + } + + pub fn decrypt(self, secret: &StaticSecret) -> Result, String> { + // Compute shared secret + let shared_secret = secret.diffie_hellman(&self.pubkey); + + // Compute key + let hk = Hkdf::::new(None, shared_secret.as_bytes()); + let mut symmetric_key = [0u8; 32]; + if hk + .expand(SALT_STRING.as_bytes(), &mut symmetric_key) + .is_err() + { + return Err("Failed to decrypt".to_string()); + } + + // Decrypt with key + let key: Key = symmetric_key.into(); + let cipher = Aes256Gcm::new(&key); + let nonce = Nonce::from_slice(&self.nonce); + match cipher.decrypt(nonce, &*self.ct) { + Ok(m) => Ok(m), + Err(_) => Err("Failed to decrypt".to_string()), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 0d2c233..db86c58 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,9 +10,11 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, fmt, }; +use x25519_dalek::{PublicKey, StaticSecret}; pub mod analysis; pub mod bridge_verification_info; +pub mod crypto; pub mod extra_info; pub mod negative_report; pub mod positive_report; @@ -318,6 +320,55 @@ pub async fn update_extra_infos( // Process negative reports +/// If there is already a negative report ECDH key for this date, return None. +/// Otherwise, generate a new keypair, save the secret part in the db, and +/// return the public part. +pub fn new_negative_report_key(db: &Db, date: u32) -> Option { + let mut nr_keys = if !db.contains_key("nr-keys").unwrap() { + BTreeMap::::new() + } else { + match bincode::deserialize(&db.get("nr-keys").unwrap().unwrap()) { + Ok(v) => v, + Err(_) => BTreeMap::::new(), + } + }; + if nr_keys.contains_key(&date) { + None + } else { + let mut rng = rand::thread_rng(); + let secret = StaticSecret::random_from_rng(&mut rng); + let public = PublicKey::from(&secret); + nr_keys.insert(date, secret); + db.insert("nr-keys", bincode::serialize(&nr_keys).unwrap()) + .unwrap(); + Some(public) + } +} + +/// Receive an encrypted negative report. Attempt to decrypt it and if +/// successful, add it to the database to be processed later. +pub fn handle_encrypted_negative_report(db: &Db, enc_report: EncryptedNegativeReport) { + if db.contains_key("nr-keys").unwrap() { + let nr_keys: BTreeMap = + match bincode::deserialize(&db.get("nr-keys").unwrap().unwrap()) { + Ok(map) => map, + Err(_) => { + return; + } + }; + if nr_keys.contains_key(&enc_report.date) { + let secret = nr_keys.get(&enc_report.date).unwrap(); + let nr = match enc_report.decrypt(&secret) { + Ok(nr) => nr, + Err(_) => { + return; + } + }; + save_negative_report_to_process(&db, nr); + } + } +} + /// We store to-be-processed negative reports as a vector. Add this NR /// to that vector (or create a new vector if necessary) pub fn save_negative_report_to_process(db: &Db, nr: NegativeReport) { diff --git a/src/negative_report.rs b/src/negative_report.rs index 45a4480..3057a2d 100644 --- a/src/negative_report.rs +++ b/src/negative_report.rs @@ -1,6 +1,6 @@ use crate::{ - bridge_verification_info::BridgeVerificationInfo, get_date, BridgeDistributor, COUNTRY_CODES, - MAX_BACKDATE, + bridge_verification_info::BridgeVerificationInfo, crypto::EciesCiphertext, get_date, + BridgeDistributor, COUNTRY_CODES, MAX_BACKDATE, }; use curve25519_dalek::scalar::Scalar; @@ -9,12 +9,14 @@ use rand::RngCore; use serde::{Deserialize, Serialize}; use sha1::{Digest, Sha1}; use sha3::Sha3_256; +use x25519_dalek::{PublicKey, StaticSecret}; #[derive(Debug, Serialize)] pub enum NegativeReportError { DateInFuture, DateInPast, // report is more than MAX_BACKDATE days old - FailedToDeserialize, // couldn't deserialize to SerializableNegativeReport + FailedToDecrypt, // couldn't decrypt to SerializableNegativeReport + FailedToDeserialize, // couldn't deserialize to NegativeReport InvalidCountryCode, MissingCountryCode, } @@ -108,6 +110,17 @@ impl NegativeReport { NegativeReport::from_lox_bucket(bridge_id, cred.bucket, country) } + pub fn encrypt(self, server_pub: &PublicKey) -> EncryptedNegativeReport { + EncryptedNegativeReport { + date: self.date, + ciphertext: EciesCiphertext::encrypt( + &bincode::serialize(&self.to_serializable_report()).unwrap(), + server_pub, + ) + .unwrap(), + } + } + /// Convert report to a serializable version pub fn to_serializable_report(self) -> SerializableNegativeReport { SerializableNegativeReport { @@ -199,6 +212,28 @@ impl SerializableNegativeReport { } } +/// Negative reports should be sent encrypted. This struct provides an +/// encrypted serializable negative report. +#[derive(Serialize, Deserialize)] +pub struct EncryptedNegativeReport { + /// The date field in the report. This is used to determine which key to use + /// to decrypt the report. + pub date: u32, + ciphertext: EciesCiphertext, +} + +impl EncryptedNegativeReport { + pub fn decrypt(self, secret: &StaticSecret) -> Result { + match self.ciphertext.decrypt(&secret) { + Ok(m) => match bincode::deserialize::(&m) { + Ok(ser_report) => ser_report.to_report(), + Err(_) => Err(NegativeReportError::FailedToDeserialize), + }, + Err(_) => Err(NegativeReportError::FailedToDecrypt), + } + } +} + /// Proof that the user knows (and should be able to access) a given bridge #[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)] pub enum ProofOfBridgeKnowledge { diff --git a/src/request_handler.rs b/src/request_handler.rs index 19ac8f0..93636d4 100644 --- a/src/request_handler.rs +++ b/src/request_handler.rs @@ -1,4 +1,4 @@ -use crate::{negative_report::NegativeReport, positive_report::PositiveReport, *}; +use crate::{negative_report::EncryptedNegativeReport, positive_report::PositiveReport, *}; use hyper::{body, header::HeaderValue, Body, Method, Request, Response, StatusCode}; use serde_json::json; use sled::Db; @@ -17,15 +17,17 @@ pub async fn handle(db: &Db, req: Request) -> Result, Infal _ => match (req.method(), req.uri().path()) { (&Method::POST, "/negativereport") => Ok::<_, Infallible>({ let bytes = body::to_bytes(req.into_body()).await.unwrap(); - let nr = match NegativeReport::from_slice(&bytes) { - Ok(nr) => nr, + // We cannot depend on the transport layer providing E2EE, so + // positive reports should be separately encrypted. + let enr: EncryptedNegativeReport = match bincode::deserialize(&bytes) { + Ok(enr) => enr, Err(e) => { - let response = json!({"error": e}); + let response = json!({"error": e.to_string()}); let val = serde_json::to_string(&response).unwrap(); return Ok(prepare_header(val)); } }; - save_negative_report_to_process(&db, nr); + handle_encrypted_negative_report(&db, enr); prepare_header("OK".to_string()) }), (&Method::POST, "/positivereport") => Ok::<_, Infallible>({ diff --git a/src/tests.rs b/src/tests.rs index e298900..8334497 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -20,6 +20,7 @@ use std::{ collections::{BTreeMap, HashMap, HashSet}, sync::{Arc, Mutex}, }; +use x25519_dalek::{PublicKey, StaticSecret}; struct TestHarness { bdb: BridgeDb, @@ -333,6 +334,7 @@ fn test_negative_reports() { assert!(!invalid_report_5.verify(&bridge_info_2)); // Test that reports with duplicate nonces are rejected + // (Also test encryption and decryption.) // Open test database let db: Db = sled::open("test_db").unwrap(); @@ -354,6 +356,24 @@ fn test_negative_reports() { BridgeDistributor::Lox, ); + let valid_report_1_copy_1 = NegativeReport::new( + bridges[0].fingerprint, + ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)), + "ru".to_string(), + date, + nonce, + BridgeDistributor::Lox, + ); + + let valid_report_1_copy_2 = NegativeReport::new( + bridges[0].fingerprint, + ProofOfBridgeKnowledge::HashOfBridgeLine(HashOfBridgeLine::new(&bridges[0], date, nonce)), + "ru".to_string(), + date, + nonce, + BridgeDistributor::Lox, + ); + // Report which reuses this nonce let invalid_report_1 = NegativeReport::new( bridges[0].fingerprint, @@ -410,13 +430,40 @@ fn test_negative_reports() { "ru".to_string(), date ); - save_negative_report_to_process(&db, valid_report_1); + + // Generate key for today + let secret = StaticSecret::random_from_rng(&mut rng); + let public = PublicKey::from(&secret); + let secret_yesterday = StaticSecret::random_from_rng(&mut rng); + let public_yesterday = PublicKey::from(&secret_yesterday); + assert!(!db.contains_key("nr-keys").unwrap()); + + // Fail to add to database because we can't decrypt + handle_encrypted_negative_report(&db, valid_report_1_copy_1.encrypt(&public)); + assert!(!db.contains_key("nrs-to-process").unwrap()); + + // Store yesterday's key but not today's + let mut nr_keys = BTreeMap::::new(); + nr_keys.insert(date - 1, secret_yesterday); + db.insert("nr-keys", bincode::serialize(&nr_keys).unwrap()) + .unwrap(); + + // Fail to add to database because we still can't decrypt + handle_encrypted_negative_report(&db, valid_report_1_copy_2.encrypt(&public)); + assert!(!db.contains_key("nrs-to-process").unwrap()); + + // Store today's key + nr_keys.insert(date, secret); + db.insert("nr-keys", bincode::serialize(&nr_keys).unwrap()) + .unwrap(); + + handle_encrypted_negative_report(&db, valid_report_1.encrypt(&public)); let nrs_to_process: BTreeMap> = bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap(); let negative_reports = nrs_to_process.get(&map_key_1).unwrap(); assert_eq!(negative_reports.len(), 1); - save_negative_report_to_process(&db, invalid_report_1); // no change + handle_encrypted_negative_report(&db, invalid_report_1.encrypt(&public)); // no change let nrs_to_process: BTreeMap> = bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap(); let negative_reports = nrs_to_process.get(&map_key_1).unwrap(); @@ -428,7 +475,7 @@ fn test_negative_reports() { "ru".to_string(), date ); - save_negative_report_to_process(&db, invalid_report_2); // no change + handle_encrypted_negative_report(&db, invalid_report_2.encrypt(&public)); // no change let nrs_to_process: BTreeMap> = bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap(); assert!(!nrs_to_process.contains_key(&map_key_2)); @@ -439,13 +486,13 @@ fn test_negative_reports() { "ru".to_string(), date - 1 ); - save_negative_report_to_process(&db, valid_report_2); + handle_encrypted_negative_report(&db, valid_report_2.encrypt(&public_yesterday)); let nrs_to_process: BTreeMap> = bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap(); let negative_reports = nrs_to_process.get(&map_key_3).unwrap(); assert_eq!(negative_reports.len(), 1); - save_negative_report_to_process(&db, valid_report_3); + handle_encrypted_negative_report(&db, valid_report_3.encrypt(&public)); let nrs_to_process: BTreeMap> = bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap(); let negative_reports = nrs_to_process.get(&map_key_1).unwrap(); @@ -457,6 +504,10 @@ fn test_negative_reports() { db.clear().unwrap(); assert!(!db.contains_key("nrs-to-process").unwrap()); + // Re-generate keys and save in database + let public = new_negative_report_key(&db, date).unwrap(); + let public_yesterday = new_negative_report_key(&db, date - 1).unwrap(); + let mut nonce = [0; 32]; rng.fill_bytes(&mut nonce); @@ -522,13 +573,13 @@ fn test_negative_reports() { "ru".to_string(), date ); - save_negative_report_to_process(&db, valid_report_1); + handle_encrypted_negative_report(&db, valid_report_1.encrypt(&public)); let nrs_to_process: BTreeMap> = bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap(); let negative_reports = nrs_to_process.get(&map_key_1).unwrap(); assert_eq!(negative_reports.len(), 1); - save_negative_report_to_process(&db, invalid_report_1); // no change + handle_encrypted_negative_report(&db, invalid_report_1.encrypt(&public)); // no change let nrs_to_process: BTreeMap> = bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap(); let negative_reports = nrs_to_process.get(&map_key_1).unwrap(); @@ -540,7 +591,7 @@ fn test_negative_reports() { "ru".to_string(), date ); - save_negative_report_to_process(&db, invalid_report_2); // no change + handle_encrypted_negative_report(&db, invalid_report_2.encrypt(&public)); // no change let nrs_to_process: BTreeMap> = bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap(); assert!(!nrs_to_process.contains_key(&map_key_2)); @@ -551,13 +602,13 @@ fn test_negative_reports() { "ru".to_string(), date - 1 ); - save_negative_report_to_process(&db, valid_report_2); + handle_encrypted_negative_report(&db, valid_report_2.encrypt(&public_yesterday)); let nrs_to_process: BTreeMap> = bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap(); let negative_reports = nrs_to_process.get(&map_key_3).unwrap(); assert_eq!(negative_reports.len(), 1); - save_negative_report_to_process(&db, valid_report_3); + handle_encrypted_negative_report(&db, valid_report_3.encrypt(&public)); let nrs_to_process: BTreeMap> = bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap(); let negative_reports = nrs_to_process.get(&map_key_1).unwrap();