Browse Source

Encrypt negative reports

Vecna 3 weeks ago
parent
commit
43228e18c9
7 changed files with 238 additions and 18 deletions
  1. 3 0
      Cargo.toml
  2. 3 0
      src/bin/server.rs
  3. 75 0
      src/crypto.rs
  4. 51 0
      src/lib.rs
  5. 38 3
      src/negative_report.rs
  6. 7 5
      src/request_handler.rs
  7. 61 10
      src/tests.rs

+ 3 - 0
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"

+ 3 - 0
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<Command>) {

+ 75 - 0
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<u8>,
+    ct: Vec<u8>,
+}
+
+impl EciesCiphertext {
+    pub fn encrypt(message: &[u8], receiver_pubkey: &PublicKey) -> Result<Self, String> {
+        // 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::<Sha3_256>::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<Aes256Gcm> = 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<Vec<u8>, String> {
+        // Compute shared secret
+        let shared_secret = secret.diffie_hellman(&self.pubkey);
+
+        // Compute key
+        let hk = Hkdf::<Sha3_256>::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<Aes256Gcm> = 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()),
+        }
+    }
+}

+ 51 - 0
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<PublicKey> {
+    let mut nr_keys = if !db.contains_key("nr-keys").unwrap() {
+        BTreeMap::<u32, StaticSecret>::new()
+    } else {
+        match bincode::deserialize(&db.get("nr-keys").unwrap().unwrap()) {
+            Ok(v) => v,
+            Err(_) => BTreeMap::<u32, StaticSecret>::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<u32, StaticSecret> =
+            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) {

+ 38 - 3
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<NegativeReport, NegativeReportError> {
+        match self.ciphertext.decrypt(&secret) {
+            Ok(m) => match bincode::deserialize::<SerializableNegativeReport>(&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 {

+ 7 - 5
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<Body>) -> Result<Response<Body>, 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>({

+ 61 - 10
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::<u32, StaticSecret>::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<String, Vec<SerializableNegativeReport>> =
         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<String, Vec<SerializableNegativeReport>> =
         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<String, Vec<SerializableNegativeReport>> =
         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<String, Vec<SerializableNegativeReport>> =
         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<String, Vec<SerializableNegativeReport>> =
         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<String, Vec<SerializableNegativeReport>> =
         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<String, Vec<SerializableNegativeReport>> =
         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<String, Vec<SerializableNegativeReport>> =
         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<String, Vec<SerializableNegativeReport>> =
         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<String, Vec<SerializableNegativeReport>> =
         bincode::deserialize(&db.get("nrs-to-process").unwrap().unwrap()).unwrap();
     let negative_reports = nrs_to_process.get(&map_key_1).unwrap();