diff --git a/ooniauth-core/benches/bench_client.rs b/ooniauth-core/benches/bench_client.rs index 64ef9f6..5debc7e 100644 --- a/ooniauth-core/benches/bench_client.rs +++ b/ooniauth-core/benches/bench_client.rs @@ -96,6 +96,7 @@ fn bench_user_submit_request(c: &mut Criterion) { let today = ServerState::today(); let age_range = (today - 30)..(today + 1); let measurement_count_range = 0..100; + let measurement_hash = [1u8; 32]; c.bench_function("user.submit_request", |b| { b.iter_batched( @@ -112,6 +113,7 @@ fn bench_user_submit_request(c: &mut Criterion) { &mut rng, "US".to_string(), "AS1234".to_string(), + &measurement_hash, age_range.clone(), measurement_count_range.clone(), ) @@ -129,6 +131,7 @@ fn bench_user_handle_submit_response(c: &mut Criterion) { let today = ServerState::today(); let age_range = (today - 30)..(today + 1); let measurement_count_range = 0..100; + let measurement_hash = [1u8; 32]; c.bench_function("user.handle_submit_response", |b| { b.iter_batched( @@ -144,6 +147,7 @@ fn bench_user_handle_submit_response(c: &mut Criterion) { &mut rng, "US".to_string(), "AS1234".to_string(), + &measurement_hash, age_range.clone(), measurement_count_range.clone(), ) @@ -155,6 +159,7 @@ fn bench_user_handle_submit_response(c: &mut Criterion) { &nym, "US", "AS1234", + &measurement_hash, age_range.clone(), measurement_count_range.clone(), ) diff --git a/ooniauth-core/benches/bench_server.rs b/ooniauth-core/benches/bench_server.rs index 47e5d3b..d8d6b83 100644 --- a/ooniauth-core/benches/bench_server.rs +++ b/ooniauth-core/benches/bench_server.rs @@ -38,11 +38,13 @@ fn bench_submit(c: &mut Criterion) { let age_range = (today - 30)..(today + 1); let msm_range = 0..100; + let measurement_hash = [1u8; 32]; let ((req, _), nym) = user .submit_request( &mut rng, cc.into(), asn.into(), + &measurement_hash, age_range.clone(), msm_range.clone(), ) @@ -54,6 +56,7 @@ fn bench_submit(c: &mut Criterion) { black_box(&nym), black_box(cc), black_box(asn), + black_box(&measurement_hash), black_box(age_range.clone()), black_box(msm_range.clone()), ) diff --git a/ooniauth-core/examples/basic_usage.rs b/ooniauth-core/examples/basic_usage.rs index 9701edd..30b336f 100644 --- a/ooniauth-core/examples/basic_usage.rs +++ b/ooniauth-core/examples/basic_usage.rs @@ -1,5 +1,6 @@ use std::time::Instant; +use ooniauth_core::submit::submit_measurement_hash; use ooniauth_core::{scalar_u32, ServerState, UserState}; use tracing_forest::util::LevelFilter; use tracing_forest::ForestLayer; @@ -97,12 +98,14 @@ fn main() -> Result<(), Box> { let today = ServerState::today(); let age_range = (today - 30)..(today + 1); let measurement_count_range = 0..100; + let measurement_hash = submit_measurement_hash(b"measurement:US:AS1234"); let now = Instant::now(); let ((submit_request, submit_state), nym) = user.submit_request( &mut rng, probe_cc.clone(), probe_asn.clone(), + &measurement_hash, age_range.clone(), measurement_count_range.clone(), )?; @@ -130,6 +133,7 @@ fn main() -> Result<(), Box> { &nym, &probe_cc, &probe_asn, + &measurement_hash, age_range, measurement_count_range, )?; @@ -175,12 +179,14 @@ fn main() -> Result<(), Box> { let age_range2 = (today - 30)..(today + 1); let measurement_count_range2 = 0..100; + let measurement_hash2 = submit_measurement_hash(b"measurement:UK:AS5678"); let now = Instant::now(); let ((submit_request2, submit_state2), nym2) = user.submit_request( &mut rng, probe_cc2.clone(), probe_asn2.clone(), + &measurement_hash2, age_range2.clone(), measurement_count_range2.clone(), )?; @@ -203,6 +209,7 @@ fn main() -> Result<(), Box> { &nym2, &probe_cc2, &probe_asn2, + &measurement_hash2, age_range2, measurement_count_range2, )?; diff --git a/ooniauth-core/src/registration.rs b/ooniauth-core/src/registration.rs index b240bea..16f174d 100644 --- a/ooniauth-core/src/registration.rs +++ b/ooniauth-core/src/registration.rs @@ -14,7 +14,7 @@ use rand::{CryptoRng, RngCore}; use sha2::Sha512; use tracing::{instrument, trace}; -const SESSION_ID: &[u8] = b"registration"; +const SESSION_ID: &[u8] = b"ooni.org/userauth/v1/reg"; CMZ! { UserAuthCredential: nym_id, diff --git a/ooniauth-core/src/submit.rs b/ooniauth-core/src/submit.rs index 2445121..4499d83 100644 --- a/ooniauth-core/src/submit.rs +++ b/ooniauth-core/src/submit.rs @@ -9,8 +9,29 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256, Sha512}; use tracing::{debug, instrument, trace}; -const SESSION_ID: &[u8] = b"submit"; -const PROBE_ID_SALT: &[u8] = b"ooni.org/userauth/v1"; +const PROBE_ID_SALT: &[u8] = b"ooni.org/userauth/v1/pid"; +const SUBMIT_SESSION_ID_SALT: &[u8] = b"ooni.org/v1/sid"; +pub type MeasurementHash = [u8; 32]; +pub type SubmitSessionId = [u8; 32]; + +/// Hash measurement material for submit proof binding. +pub fn submit_measurement_hash(measurement: &[u8]) -> MeasurementHash { + let measurement_hash = Sha256::digest(measurement); + let mut out = [0u8; 32]; + out.copy_from_slice(&measurement_hash); + out +} + +/// Derive the submit proof session ID from a 32-byte measurement hash. +pub fn submit_session_id(measurement_hash: &MeasurementHash) -> SubmitSessionId { + let mut hasher = Sha256::new(); + hasher.update(SUBMIT_SESSION_ID_SALT); + hasher.update(measurement_hash); + let digest = hasher.finalize(); + let mut out = [0u8; 32]; + out.copy_from_slice(&digest); + out +} muCMZProtocol!(submit, @@ -57,12 +78,21 @@ fn digest_point(point: RistrettoPoint) -> [u8; 32] { } impl UserState { - #[instrument(skip(self, rng, probe_cc, probe_asn, age_range, measurement_count_range))] + #[instrument(skip( + self, + rng, + probe_cc, + probe_asn, + measurement_hash, + age_range, + measurement_count_range + ))] pub fn submit_request( &self, rng: &mut (impl RngCore + CryptoRng), probe_cc: String, probe_asn: String, + measurement_hash: &MeasurementHash, age_range: std::ops::Range, measurement_count_range: std::ops::Range, ) -> Result<((SubmitRequest, submit::ClientState), [u8; 32]), CredentialError> { @@ -153,7 +183,8 @@ impl UserState { }; trace!("Preparing submit proof with params"); - match submit::prepare(rng, SESSION_ID, Old, New, ¶ms) { + let session_id = submit_session_id(measurement_hash); + match submit::prepare(rng, &session_id, Old, New, ¶ms) { Ok((core_request, client_state)) => { debug!("Submit request prepared successfully"); let probe_id = digest_point(NYM); @@ -196,6 +227,7 @@ impl ServerState { probe_id, probe_cc, probe_asn, + measurement_hash, age_range, measurement_count_range ))] @@ -206,6 +238,7 @@ impl ServerState { probe_id: &[u8; 32], probe_cc: &str, probe_asn: &str, + measurement_hash: &MeasurementHash, age_range: std::ops::Range, measurement_count_range: std::ops::Range, ) -> Result { @@ -238,9 +271,10 @@ impl ServerState { let server_sk = self.sk.clone(); let server_pp = self.pp.clone(); + let session_id = submit_session_id(measurement_hash); match submit::handle( rng, - SESSION_ID, + &session_id, recvreq, move |Old: &mut UserAuthCredential, New: &mut UserAuthCredential| { // Set the private key for the credentials - this is essential for the protocol @@ -336,11 +370,13 @@ mod tests { let today = ServerState::today(); let age_range = (today - 30)..(today + 1); // Credential valid for 30 days let measurement_count_range = 0..100; + let measurement_hash = submit_measurement_hash(b"measurement:US:AS1234"); let result = user_state.submit_request( rng, probe_cc.clone(), probe_asn.clone(), + &measurement_hash, age_range.clone(), measurement_count_range.clone(), ); @@ -367,6 +403,7 @@ mod tests { &nym, &probe_cc, &probe_asn, + &measurement_hash, age_range, measurement_count_range, ); @@ -394,4 +431,69 @@ mod tests { let new_count = scalar_u32(&updated_cred.measurement_count.unwrap()).unwrap(); assert_eq!(new_count, 1, "Measurement count should be incremented to 1"); } + + #[test] + fn test_submit_request_rejects_replaced_measurement() { + let rng = &mut rand::thread_rng(); + + let server_state = ServerState::new(rng); + let mut user_state = UserState::new(server_state.public_parameters()); + + let (reg_request, reg_client_state) = user_state.request(rng).unwrap(); + let reg_response = server_state.open_registration(reg_request).unwrap(); + user_state + .handle_response(reg_client_state, reg_response) + .unwrap(); + + let probe_cc = "US".to_string(); + let probe_asn = "AS1234".to_string(); + let today = ServerState::today(); + let age_range = (today - 30)..(today + 1); + let measurement_count_range = 0..100; + let original_measurement_hash = submit_measurement_hash(b"measurement:US:AS1234"); + let replaced_measurement_hash = submit_measurement_hash(b"measurement:US:AS1234:replaced"); + + let ((request, _client_state), nym) = user_state + .submit_request( + rng, + probe_cc.clone(), + probe_asn.clone(), + &original_measurement_hash, + age_range.clone(), + measurement_count_range.clone(), + ) + .unwrap(); + + assert_ne!(original_measurement_hash, replaced_measurement_hash); + + let replaced_result = server_state.handle_submit( + rng, + request.clone(), + &nym, + &probe_cc, + &probe_asn, + &replaced_measurement_hash, + age_range.clone(), + measurement_count_range.clone(), + ); + assert!( + replaced_result.is_err(), + "Server should reject a submit request verified against a replaced measurement" + ); + + let original_result = server_state.handle_submit( + rng, + request, + &nym, + &probe_cc, + &probe_asn, + &original_measurement_hash, + age_range, + measurement_count_range, + ); + assert!( + original_result.is_ok(), + "Server should accept the submit request with the original measurement session ID" + ); + } } diff --git a/ooniauth-core/src/update.rs b/ooniauth-core/src/update.rs index e519e23..9524c70 100644 --- a/ooniauth-core/src/update.rs +++ b/ooniauth-core/src/update.rs @@ -6,7 +6,7 @@ use group::Group; use rand::{CryptoRng, RngCore}; use sha2::Sha512; -const SESSION_ID: &[u8] = b"update"; +const SESSION_ID: &[u8] = b"ooni.org/userauth/v1/upd"; muCMZProtocol!(update, Old: UserAuthCredential { nym_id: H, age: H, measurement_count: H}, diff --git a/ooniauth-ffi/src/lib.rs b/ooniauth-ffi/src/lib.rs index ced4525..87ee548 100644 --- a/ooniauth-ffi/src/lib.rs +++ b/ooniauth-ffi/src/lib.rs @@ -3,6 +3,7 @@ use std::sync::Once; use std::time::Instant; use ooniauth_core::registration::UserAuthCredential; +use ooniauth_core::submit::submit_measurement_hash; use ooniauth_core::{scalar_u32, ServerState, UserState}; use tracing_forest::util::LevelFilter; use tracing_forest::ForestLayer; @@ -159,6 +160,7 @@ fn run_basic_usage_demo() -> Result { let today = ServerState::today(); let age_range = (today - 30)..(today + 1); let measurement_count_range = 0..100; + let measurement_hash = submit_measurement_hash(b"measurement:US:AS1234"); let now = Instant::now(); let ((submit_request, submit_state), nym) = user @@ -166,6 +168,7 @@ fn run_basic_usage_demo() -> Result { &mut rng, probe_cc.clone(), probe_asn.clone(), + &measurement_hash, age_range.clone(), measurement_count_range.clone(), ) @@ -193,6 +196,7 @@ fn run_basic_usage_demo() -> Result { &nym, &probe_cc, &probe_asn, + &measurement_hash, age_range, measurement_count_range, ) @@ -234,6 +238,7 @@ fn run_basic_usage_demo() -> Result { let age_range2 = (today - 30)..(today + 1); let measurement_count_range2 = 0..100; + let measurement_hash2 = submit_measurement_hash(b"measurement:UK:AS5678"); let now = Instant::now(); let ((submit_request2, submit_state2), nym2) = user @@ -241,6 +246,7 @@ fn run_basic_usage_demo() -> Result { &mut rng, probe_cc2.clone(), probe_asn2.clone(), + &measurement_hash2, age_range2.clone(), measurement_count_range2.clone(), ) @@ -264,6 +270,7 @@ fn run_basic_usage_demo() -> Result { &nym2, &probe_cc2, &probe_asn2, + &measurement_hash2, age_range2, measurement_count_range2, ) diff --git a/ooniauth-py/ooniauth_py.pyi b/ooniauth-py/ooniauth_py.pyi index 2e56892..340583d 100644 --- a/ooniauth-py/ooniauth_py.pyi +++ b/ooniauth-py/ooniauth_py.pyi @@ -48,6 +48,7 @@ class ServerState: request: str, probe_cc: str, probe_asn: str, + measurement_hash: str, age_range: tuple[builtins.int, builtins.int], min_measurement_count: builtins.int, ) -> str: ... @@ -78,6 +79,7 @@ class UserState: self, probe_cc: str, probe_asn: str, + measurement_hash: str, age_range: tuple[builtins.int, builtins.int], min_measurement_count: builtins.int, ) -> SubmitRequest: ... diff --git a/ooniauth-py/src/protocol.rs b/ooniauth-py/src/protocol.rs index 54dad26..c5326b9 100644 --- a/ooniauth-py/src/protocol.rs +++ b/ooniauth-py/src/protocol.rs @@ -22,6 +22,22 @@ fn py_string_arg<'py>( }) } +fn base64_32_arg<'py>( + py: Python<'py>, + value: &'py Py, + name: &str, +) -> OoniResult<[u8; 32]> { + BASE64_STANDARD + .decode(py_string_arg(py, value, name)?) + .map_err(|e| OoniErr::DeserializationFailed { + reason: e.to_string(), + })? + .try_into() + .map_err(|value: Vec| OoniErr::DeserializationFailed { + reason: format!("{name} must decode to 32 bytes, got {}", value.len()), + }) +} + /// Returns the version of the `ooniauth-core`, the actual protocol implementation. #[gen_stub_pyfunction(module = "ooniauth-py")] #[pyfunction] @@ -96,19 +112,13 @@ impl ServerState { request: Py, probe_cc: Py, probe_asn: Py, + measurement_hash: Py, age_range: (u32, u32), min_measurement_count: u32, ) -> OoniResult> { // Convert arguments from py types to rust types - let nym: [u8; 32] = BASE64_STANDARD - .decode(py_string_arg(py, &nym, "nym")?) - .map_err(|e| OoniErr::DeserializationFailed { - reason: e.to_string(), - })? - .try_into() - .map_err(|nym: Vec| OoniErr::DeserializationFailed { - reason: format!("nym must decode to 32 bytes, got {}", nym.len()), - })?; + let nym = base64_32_arg(py, &nym, "nym")?; + let measurement_hash = base64_32_arg(py, &measurement_hash, "measurement_hash")?; let request = from_pystring::(py, &request)?; let probe_cc = py_string_arg(py, &probe_cc, "probe_cc")?; @@ -122,6 +132,7 @@ impl ServerState { &nym, probe_cc, probe_asn, + &measurement_hash, age_range.0..age_range.1, min_measurement_count..u32::MAX, )?; @@ -227,17 +238,20 @@ impl UserState { py: Python<'_>, probe_cc: Py, probe_asn: Py, + measurement_hash: Py, age_range: (u32, u32), min_measurement_count: u32, ) -> OoniResult { let probe_cc = probe_cc.to_str(py).expect("unable to get string"); let probe_asn = probe_asn.to_str(py).expect("unable to get string"); + let measurement_hash = base64_32_arg(py, &measurement_hash, "measurement_hash")?; let mut rng = rand::thread_rng(); let ((result, client_state), nym) = self.state.submit_request( &mut rng, probe_cc.into(), probe_asn.into(), + &measurement_hash, age_range.0..age_range.1, min_measurement_count..u32::MAX, )?; @@ -318,6 +332,10 @@ mod tests { use pyo3::{types::PyString, Py, Python}; use rand::{rngs::ThreadRng, thread_rng}; + fn test_measurement_hash(py: Python<'_>, value: u8) -> Py { + PyString::new(py, &BASE64_STANDARD.encode([value; 32])).into() + } + #[test] fn test_encoding_verifies() { // Check that the string encoding still let us verify @@ -356,11 +374,13 @@ mod tests { let today = ServerState::today(); let age_tuple = (today - 30, today + 1); let min_msm = 0u32; + let measurement_hash = test_measurement_hash(py, 1); let submit_req = client .make_submit_request( py, cc.clone().into(), asn.clone().into(), + measurement_hash.clone_ref(py), age_tuple, min_msm, ) @@ -373,6 +393,7 @@ mod tests { submit_req.request, cc.into(), asn.into(), + measurement_hash, age_tuple, min_msm, ) @@ -451,12 +472,14 @@ mod tests { let today = ServerState::today(); let age_tuple = (today - 30, today + 1); let min_msm = 0u32; + let measurement_hash = test_measurement_hash(py, 1); let submit = client .make_submit_request( py, probe_cc.clone_ref(py), probe_asn.clone_ref(py), + measurement_hash.clone_ref(py), age_tuple, min_msm, ) @@ -469,6 +492,7 @@ mod tests { submit.request, probe_cc.clone_ref(py), probe_asn.clone_ref(py), + measurement_hash, age_tuple, min_msm, ) @@ -497,11 +521,13 @@ mod tests { .expect("Bad credential update response"); // Now make sure you can send another measurement + let measurement_hash = test_measurement_hash(py, 2); let submit = client .make_submit_request( py, probe_cc.clone_ref(py), probe_asn.clone_ref(py), + measurement_hash.clone_ref(py), age_tuple, min_msm, ) @@ -514,6 +540,7 @@ mod tests { submit.request, probe_cc, probe_asn, + measurement_hash, age_tuple, min_msm, ) @@ -532,6 +559,7 @@ mod tests { crate::SubmitRequest, Py, Py, + Py, (u32, u32), u32, ) { @@ -545,20 +573,39 @@ mod tests { let today = crate::ServerState::today(); let age_tuple = (today - 30, today + 1); let min_msm = 0u32; + let measurement_hash = test_measurement_hash(py, 1); let submit = client .make_submit_request(py, cc.clone_ref(py), asn.clone_ref(py), age_tuple, min_msm) .unwrap(); - (server, submit, cc, asn, age_tuple, min_msm) + ( + server, + submit, + cc, + asn, + measurement_hash, + age_tuple, + min_msm, + ) } #[test] fn test_handle_submit_request_rejects_short_nym() { pyo3::Python::initialize(); Python::attach(|py| { - let (server, submit, cc, asn, age_range, min_msm) = submit_fixture(py); + let (server, submit, cc, asn, measurement_hash, age_range, min_msm) = + submit_fixture(py); let bad_nym = PyString::new(py, &BASE64_STANDARD.encode([7u8; 31])).into(); let err = server - .handle_submit_request(py, bad_nym, submit.request, cc, asn, age_range, min_msm) + .handle_submit_request( + py, + bad_nym, + submit.request, + cc, + asn, + measurement_hash, + age_range, + min_msm, + ) .unwrap_err(); assert!(matches!(err, OoniErr::DeserializationFailed { .. })); });