From 2759c5c838a576d3e556cd0073e8e16e8fc63dc3 Mon Sep 17 00:00:00 2001 From: Mathieu Leplatre Date: Thu, 19 Dec 2024 13:53:00 +0100 Subject: [PATCH] Add signatures optional feature --- Cargo.lock | 8 + components/remote_settings/Cargo.toml | 5 +- components/remote_settings/src/client.rs | 474 ++++++++++++++++++- components/remote_settings/src/error.rs | 8 +- components/remote_settings/src/lib.rs | 3 +- components/remote_settings/src/signatures.rs | 75 ++- 6 files changed, 544 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50d4ee9dde..b37cd2c8c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2993,6 +2993,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mock_instant" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcebb6db83796481097dedc7747809243cc81d9ed83e6a938b76d4ea0b249cf" + [[package]] name = "mockall" version = "0.11.3" @@ -4156,9 +4162,11 @@ dependencies = [ "firefox-versioning", "jexl-eval", "log", + "mock_instant", "mockall", "mockito", "parking_lot", + "rc_crypto", "regex", "rusqlite", "serde", diff --git a/components/remote_settings/Cargo.toml b/components/remote_settings/Cargo.toml index 3e50b1f3b8..38bd90f7f1 100644 --- a/components/remote_settings/Cargo.toml +++ b/components/remote_settings/Cargo.toml @@ -11,6 +11,7 @@ exclude = ["/android", "/ios"] [features] default = [] jexl = ["dep:jexl-eval"] +signatures = ["dep:canonical_json", "dep:rc_crypto"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -31,7 +32,8 @@ regex = "1.9" anyhow = "1.0" firefox-versioning = { path = "../support/firefox-versioning" } sha2 = "^0.10" -canonical_json = "0.5" +canonical_json = { version = "0.5", optional = true } +rc_crypto = { path = "../support/rc_crypto", optional = true } [build-dependencies] uniffi = { version = "0.28.2", features = ["build"] } @@ -44,3 +46,4 @@ mockito = "0.31" # We add the perserve_order feature to guarantee ordering of the keys in our # JSON objects as they get serialized/deserialized. serde_json = { version = "1", features = ["preserve_order"] } +mock_instant = "0.5.1" diff --git a/components/remote_settings/src/client.rs b/components/remote_settings/src/client.rs index 701a6c4215..c116f48e6e 100644 --- a/components/remote_settings/src/client.rs +++ b/components/remote_settings/src/client.rs @@ -6,6 +6,8 @@ use crate::config::RemoteSettingsConfig; use crate::error::{Error, Result}; #[cfg(feature = "jexl")] use crate::jexl_filter::JexlFilter; +#[cfg(feature = "signatures")] +use crate::signatures; use crate::storage::Storage; #[cfg(feature = "jexl")] use crate::RemoteSettingsContext; @@ -22,6 +24,29 @@ use std::{ use url::Url; use viaduct::{Request, Response}; +#[cfg(feature = "signatures")] +#[cfg(not(test))] +use std::time::{SystemTime, UNIX_EPOCH}; + +#[cfg(feature = "signatures")] +#[cfg(test)] +use mock_instant; + +#[cfg(feature = "signatures")] +#[cfg(not(test))] +fn epoch_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() // Time won't go backwards. + .as_secs() +} + +#[cfg(feature = "signatures")] +#[cfg(test)] +fn epoch_seconds() -> u64 { + mock_instant::thread_local::MockClock::time().as_secs() +} + const HEADER_BACKOFF: &str = "Backoff"; const HEADER_ETAG: &str = "ETag"; const HEADER_RETRY_AFTER: &str = "Retry-After"; @@ -189,26 +214,111 @@ impl RemoteSettingsClient { } pub fn sync(&self) -> Result<()> { + { + let mut inner = self.inner.lock(); + let collection_url = inner.api_client.collection_url(); + let mtime = inner.storage.get_last_modified_timestamp(&collection_url)?; + let changeset = inner.api_client.fetch_changeset(mtime)?; + let _ = inner.storage.insert_collection_content( + &collection_url, + &changeset.changes, + changeset.timestamp, + changeset.metadata, + ); + } + match self.verify_signature() { + Ok(()) => {} + Err(_) => { + { + // Try again from empty storage. + self.reset_storage()?; + let mut inner = self.inner.lock(); + let collection_url = inner.api_client.collection_url(); + let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?; + let changeset = inner.api_client.fetch_changeset(timestamp)?; + let _ = inner.storage.insert_collection_content( + &collection_url, + &changeset.changes, + changeset.timestamp, + changeset.metadata, + ); + } + self.verify_signature()?; + } + } + Ok(()) + } + + fn reset_storage(&self) -> Result<()> { let mut inner = self.inner.lock(); let collection_url = inner.api_client.collection_url(); - let mtime = inner.storage.get_last_modified_timestamp(&collection_url)?; - let changeset = inner.api_client.fetch_changeset(mtime)?; - inner.storage.insert_collection_content( - &collection_url, - &changeset.changes, - changeset.timestamp, - changeset.metadata, - ) + inner.storage.empty()?; + let is_prod = inner.api_client.is_prod_server()?; + let packaged_data = if is_prod { + self.load_packaged_data() + } else { + None + }; + if let Some(packaged_data) = packaged_data { + inner.storage.insert_collection_content( + &collection_url, + &packaged_data.data, + packaged_data.timestamp, + CollectionMetadata::default(), + )?; + } + Ok(()) + } + + #[cfg(not(feature = "signatures"))] + fn verify_signature(&self) -> Result<()> { + Ok(()) } + #[cfg(feature = "signatures")] fn verify_signature(&self) -> Result<()> { let mut inner = self.inner.lock(); let collection_url = inner.api_client.collection_url(); let timestamp = inner.storage.get_last_modified_timestamp(&collection_url)?; let records = inner.storage.get_records(&collection_url)?; let metadata = inner.storage.get_collection_metadata(&collection_url)?; - - Ok(()) + match (timestamp, &records, metadata) { + (Some(timestamp), Some(records), Some(metadata)) => { + let cert_chain_bytes = inner.api_client.fetch_cert(&metadata.signature.x5u)?; + + // The signer name is hard-coded. This would have to be modified in the very (very) + // unlikely situation where we would add a new collection signer. + // And clients code would have to be modified to handle this new collection anyway. + // https://searchfox.org/mozilla-central/rev/df850fa290fe962c2c5ae8b63d0943ce768e3cc4/services/settings/remote-settings.sys.mjs#40-48 + let subject_cname = format!( + "{}.content-signature.mozilla.org", + if metadata.bucket.contains("security-state") { + "onecrl" + } else { + "remote-settings" + } + ); + signatures::verify_signature( + subject_cname, + cert_chain_bytes, + metadata.signature.signature.into_bytes(), + timestamp, + records.to_vec(), + epoch_seconds(), + )?; + Ok(()) + } + _ => { + let missing_field = if timestamp.is_none() { + "timestamp" + } else if records.is_none() { + "records" + } else { + "metadata" + }; + Err(Error::IncompleteSignatureDataError(missing_field.into())) + } + } } /// Downloads an attachment from [attachment_location]. NOTE: there are no guarantees about a @@ -315,6 +425,9 @@ pub trait ApiClient { /// Fetch an attachment from the server fn fetch_attachment(&mut self, attachment_location: &str) -> Result>; + /// Fetch a server certificate + fn fetch_cert(&mut self, x5u: &str) -> Result>; + /// Check if this client is pointing to the production server fn is_prod_server(&self) -> Result; } @@ -450,6 +563,11 @@ impl ApiClient for ViaductApiClient { .as_str() .starts_with(RemoteSettingsServer::Prod.get_url()?.as_str())) } + + fn fetch_cert(&mut self, x5u: &str) -> Result> { + let resp = self.make_request(Url::parse(x5u)?)?; + Ok(resp.body) + } } /// A simple HTTP client that can retrieve Remote Settings data using the properties by [ClientConfig]. @@ -2322,3 +2440,339 @@ mod test_packaged_metadata { Ok(()) } } + +#[cfg(feature = "signatures")] +#[cfg(not(feature = "jexl"))] // TODO: how tests run? +#[cfg(test)] +mod test_signatures { + use super::*; + use mock_instant::thread_local::MockClock; + + const VALID_CERTIFICATE: &str = "\ +-----BEGIN CERTIFICATE----- +MIIDBjCCAougAwIBAgIIFml6g0ldRGowCgYIKoZIzj0EAwMwgaMxCzAJBgNVBAYT +AlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3pp +bGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTFFMEMGA1UEAww8Q29u +dGVudCBTaWduaW5nIEludGVybWVkaWF0ZS9lbWFpbEFkZHJlc3M9Zm94c2VjQG1v +emlsbGEuY29tMB4XDTIxMDIwMzE1MDQwNVoXDTIxMDQyNDE1MDQwNVowgakxCzAJ +BgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFp +biBWaWV3MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5D +bG91ZCBTZXJ2aWNlczE2MDQGA1UEAxMtcmVtb3RlLXNldHRpbmdzLmNvbnRlbnQt +c2lnbmF0dXJlLm1vemlsbGEub3JnMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8pKb +HX4IiD0SCy+NO7gwKqRRZ8IhGd8PTaIHIBgM6RDLRyDeswXgV+2kGUoHyzkbNKZt +zlrS3AhqeUCtl1g6ECqSmZBbRTjCpn/UCpCnMLL0T0goxtAB8Rmi3CdM0cBUo4GD +MIGAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSME +GDAWgBQlZawrqt0eUz/t6OdN45oKfmzy6DA4BgNVHREEMTAvgi1yZW1vdGUtc2V0 +dGluZ3MuY29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMD +aQAwZgIxAPh43Bxl4MxPT6Ra1XvboN5O2OvIn2r8rHvZPWR/jJ9vcTwH9X3F0aLJ +9FiresnsLAIxAOoAcREYB24gFBeWxbiiXaG7TR/yM1/MXw4qxbN965FFUaoB+5Bc +fS8//SQGTlCqKQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF2jCCA8KgAwIBAgIEAQAAADANBgkqhkiG9w0BAQsFADCBqTELMAkGA1UEBhMC +VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK +ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu +aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv +bnNAbW96aWxsYS5jb20wHhcNMjEwMTExMDAwMDAwWhcNMjQxMTE0MjA0ODU5WjCB +ozELMAkGA1UEBhMCVVMxHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRpb24xLzAt +BgNVBAsTJk1vemlsbGEgQU1PIFByb2R1Y3Rpb24gU2lnbmluZyBTZXJ2aWNlMUUw +QwYDVQQDDDxDb250ZW50IFNpZ25pbmcgSW50ZXJtZWRpYXRlL2VtYWlsQWRkcmVz +cz1mb3hzZWNAbW96aWxsYS5jb20wdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARw1dyE +xV5aNiHJPa/fVHO6kxJn3oZLVotJ0DzFZA9r1sQf8i0+v78Pg0/c3nTAyZWfkULz +vOpKYK/GEGBtisxCkDJ+F3NuLPpSIg3fX25pH0LE15fvASBVcr8tKLVHeOmjggG6 +MIIBtjAMBgNVHRMEBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAWBgNVHSUBAf8EDDAK +BggrBgEFBQcDAzAdBgNVHQ4EFgQUJWWsK6rdHlM/7ejnTeOaCn5s8ugwgdkGA1Ud +IwSB0TCBzoAUhtg0HE5Y0RNcmV/YQpjtFA8Z8l2hga+kgawwgakxCzAJBgNVBAYT +AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEcMBoGA1UE +ChMTQWRkb25zIFRlc3QgU2lnbmluZzEkMCIGA1UEAxMbdGVzdC5hZGRvbnMuc2ln +bmluZy5yb290LmNhMTEwLwYJKoZIhvcNAQkBFiJzZWNvcHMrc3RhZ2Vyb290YWRk +b25zQG1vemlsbGEuY29tggRgJZg7MDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRk +b25zLmFsbGl6b20ub3JnL2NhL2NybC5wZW0wTgYDVR0eBEcwRaBDMCCCHi5jb250 +ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzAfgh1jb250ZW50LXNpZ25hdHVyZS5t +b3ppbGxhLm9yZzANBgkqhkiG9w0BAQsFAAOCAgEAtGTTzcPzpcdf07kIeRs9vPMx +qiF8ylW5L/IQ2NzT3sFFAvPW1vW1wZC0xAHMsuVyo+BTGrv+4mlD0AUR9acRfiTZ +9qyZ3sJbyhQwJAXLKU4YpnzuFOf58T/yOnOdwpH2ky/0FuHskMyfXaAz2Az4JXJH +TCgggqfdZNvsZ5eOnQlKoC5NadMa8oTI5sd4SyR5ANUPAtYok931MvVSz3IMbwTr +v4PPWXdl9SGXuOknSqdY6/bS1LGvC2KprsT+PBlvVtS6YgZOH0uCgTTLpnrco87O +ErzC2PJBA1Ftn3Mbaou6xy7O+YX+reJ6soNUV+0JHOuKj0aTXv0c+lXEAh4Y8nea +UGhW6+MRGYMOP2NuKv8s2+CtNH7asPq3KuTQpM5RerjdouHMIedX7wpNlNk0CYbg +VMJLxZfAdwcingLWda/H3j7PxMoAm0N+eA24TGDQPC652ZakYk4MQL/45lm0A5f0 +xLGKEe6JMZcTBQyO7ANWcrpVjKMiwot6bY6S2xU17mf/h7J32JXZJ23OPOKpMS8d +mljj4nkdoYDT35zFuS1z+5q6R5flLca35vRHzC3XA0H/XJvgOKUNLEW/IiJIqLNi +ab3Ao0RubuX+CAdFML5HaJmkyuJvL3YtwIOwe93RGcGRZSKZsnMS+uY5QN8+qKQz +LC4GzWQGSCGDyD+JCVw= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIHbDCCBVSgAwIBAgIEYCWYOzANBgkqhkiG9w0BAQwFADCBqTELMAkGA1UEBhMC +VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRwwGgYDVQQK +ExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0LmFkZG9ucy5zaWdu +aW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytzdGFnZXJvb3RhZGRv +bnNAbW96aWxsYS5jb20wHhcNMjEwMjExMjA0ODU5WhcNMjQxMTE0MjA0ODU5WjCB +qTELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBW +aWV3MRwwGgYDVQQKExNBZGRvbnMgVGVzdCBTaWduaW5nMSQwIgYDVQQDExt0ZXN0 +LmFkZG9ucy5zaWduaW5nLnJvb3QuY2ExMTAvBgkqhkiG9w0BCQEWInNlY29wcytz +dGFnZXJvb3RhZGRvbnNAbW96aWxsYS5jb20wggIiMA0GCSqGSIb3DQEBAQUAA4IC +DwAwggIKAoICAQDKRVty/FRsO4Ech6EYleyaKgAueaLYfMSsAIyPC/N8n/P8QcH8 +rjoiMJrKHRlqiJmMBSmjUZVzZAP0XJku0orLKWPKq7cATt+xhGY/RJtOzenMMsr5 +eN02V3GzUd1jOShUpERjzXdaO3pnfZqhdqNYqP9ocqQpyno7bZ3FZQ2vei+bF52k +51uPioTZo+1zduoR/rT01twGtZm3QpcwU4mO74ysyxxgqEy3kpojq8Nt6haDwzrj +khV9M6DGPLHZD71QaUiz5lOhD9CS8x0uqXhBhwMUBBkHsUDSxbN4ZhjDDWpCmwaD +OtbJMUJxDGPCr9qj49QESccb367OeXLrfZ2Ntu/US2Bw9EDfhyNsXr9dg9NHj5yf +4sDUqBHG0W8zaUvJx5T2Ivwtno1YZLyJwQW5pWeWn8bEmpQKD2KS/3y2UjlDg+YM +NdNASjFe0fh6I5NCFYmFWA73DpDGlUx0BtQQU/eZQJ+oLOTLzp8d3dvenTBVnKF+ +uwEmoNfZwc4TTWJOhLgwxA4uK+Paaqo4Ap2RGS2ZmVkPxmroB3gL5n3k3QEXvULh +7v8Psk4+MuNWnxudrPkN38MGJo7ju7gDOO8h1jLD4tdfuAqbtQLduLXzT4DJPA4y +JBTFIRMIpMqP9CovaS8VPtMFLTrYlFh9UnEGpCeLPanJr+VEj7ae5sc8YwIDAQAB +o4IBmDCCAZQwDAYDVR0TBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwFgYDVR0lAQH/ +BAwwCgYIKwYBBQUHAwMwLAYJYIZIAYb4QgENBB8WHU9wZW5TU0wgR2VuZXJhdGVk +IENlcnRpZmljYXRlMDMGCWCGSAGG+EIBBAQmFiRodHRwOi8vYWRkb25zLm1vemls +bGEub3JnL2NhL2NybC5wZW0wHQYDVR0OBBYEFIbYNBxOWNETXJlf2EKY7RQPGfJd +MIHZBgNVHSMEgdEwgc6AFIbYNBxOWNETXJlf2EKY7RQPGfJdoYGvpIGsMIGpMQsw +CQYDVQQGEwJVUzELMAkGA1UECBMCQ0ExFjAUBgNVBAcTDU1vdW50YWluIFZpZXcx +HDAaBgNVBAoTE0FkZG9ucyBUZXN0IFNpZ25pbmcxJDAiBgNVBAMTG3Rlc3QuYWRk +b25zLnNpZ25pbmcucm9vdC5jYTExMC8GCSqGSIb3DQEJARYic2Vjb3BzK3N0YWdl +cm9vdGFkZG9uc0Btb3ppbGxhLmNvbYIEYCWYOzANBgkqhkiG9w0BAQwFAAOCAgEA +nowyJv8UaIV7NA0B3wkWratq6FgA1s/PzetG/ZKZDIW5YtfUvvyy72HDAwgKbtap +Eog6zGI4L86K0UGUAC32fBjE5lWYEgsxNM5VWlQjbgTG0dc3dYiufxfDFeMbAPmD +DzpIgN3jHW2uRqa/MJ+egHhv7kGFL68uVLboqk/qHr+SOCc1LNeSMCuQqvHwwM0+ +AU1GxhzBWDkealTS34FpVxF4sT5sKLODdIS5HXJr2COHHfYkw2SW/Sfpt6fsOwaF +2iiDaK4LPWHWhhIYa6yaynJ+6O6KPlpvKYCChaTOVdc+ikyeiSO6AakJykr5Gy7d +PkkK7MDCxuY6psHj7iJQ59YK7ujQB8QYdzuXBuLLo5hc5gBcq3PJs0fLT2YFcQHA +dj+olGaDn38T0WI8ycWaFhQfKwATeLWfiQepr8JfoNlC2vvSDzGUGfdAfZfsJJZ8 +5xZxahHoTFGS0mDRfXqzKH5uD578GgjOZp0fULmzkcjWsgzdpDhadGjExRZFKlAy +iKv8cXTONrGY0fyBDKennuX0uAca3V0Qm6v2VRp+7wG/pywWwc5n+04qgxTQPxgO +6pPB9UUsNbaLMDR5QPYAWrNhqJ7B07XqIYJZSwGP5xB9NqUZLF4z+AOMYgWtDpmg +IKdcFKAt3fFrpyMhlfIKkLfmm0iDjmfmIXbDGBJw9SE= +-----END CERTIFICATE-----"; + const VALID_SIGNATURE: &str = r#"fJJcOpwdnkjEWFeHXfdOJN6GaGLuDTPGzQOxA2jn6ldIleIk6KqMhZcy2GZv2uYiGwl6DERWwpaoUfQFLyCAOcVjck1qlaaEFZGY1BQba9p99xEc9FNQ3YPPfvSSZqsw"#; + const COLLECTION_NAME: &str = "pioneer-study-addons"; + const COLLECTION_URL: &str = "https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/pioneer-study-addons"; + + #[test] + fn test_valid_signature() -> Result<()> { + // Adjust current time, since the above hard-coded certificate has expired + // since then. + let march_12_2021 = Duration::from_secs(1615559719); + MockClock::set_time(march_12_2021); + + let mut api_client = MockApiClient::new(); + let storage = Storage::new(":memory:".into())?; + + api_client + .expect_collection_url() + .returning(move || COLLECTION_URL.into()); + api_client.expect_is_prod_server().returning(|| Ok(true)); + let changeset = ChangesetResponse { + changes: vec![], + timestamp: 1603992731957, + metadata: CollectionMetadata { + bucket: "main".into(), + signature: CollectionSignature { + signature: VALID_SIGNATURE.to_string(), + x5u: "http://mocked".into(), + }, + }, + }; + api_client + .expect_fetch_changeset() + .withf(|timestamp| timestamp.is_none()) + .returning(move |_| Ok(changeset.clone())); + api_client + .expect_fetch_cert() + .returning(move |_| Ok(VALID_CERTIFICATE.as_bytes().to_vec())); + + let rs_client = + RemoteSettingsClient::new_from_parts(COLLECTION_NAME.to_string(), storage, api_client); + + rs_client.sync().expect("Verification failed"); + + Ok(()) + } + + #[test] + fn test_invalid_signature_value() -> Result<()> { + // Adjust current time, since the above hard-coded certificate has expired + // since then. + let march_12_2021 = Duration::from_secs(1615559719); + MockClock::set_time(march_12_2021); + + let mut api_client = MockApiClient::new(); + let storage = Storage::new(":memory:".into())?; + + api_client + .expect_collection_url() + .returning(move || COLLECTION_URL.into()); + api_client.expect_is_prod_server().returning(|| Ok(true)); + let changeset = ChangesetResponse { + changes: vec![], + timestamp: 1603992731957, + metadata: CollectionMetadata { + bucket: "main".into(), + signature: CollectionSignature { + signature: "invalid signature".into(), + x5u: "http://mocked".into(), + }, + }, + }; + api_client + .expect_fetch_changeset() + .returning(move |_| Ok(changeset.clone())); + api_client + .expect_fetch_cert() + .returning(move |_| Ok(VALID_CERTIFICATE.as_bytes().to_vec())); + + let rs_client = + RemoteSettingsClient::new_from_parts(COLLECTION_NAME.to_string(), storage, api_client); + + let err = rs_client.sync().unwrap_err(); + + assert!( + matches!(err, Error::SignatureError(_)), + "Signature could not be verified: PEM content format error: Invalid byte 32, offset 0." + ); + + Ok(()) + } + + #[test] + fn test_invalid_signature_expired_cert() -> Result<()> { + let december_20_2024 = Duration::from_secs(1734651582); + MockClock::set_time(december_20_2024); + + let mut api_client = MockApiClient::new(); + let storage = Storage::new(":memory:".into())?; + + api_client + .expect_collection_url() + .returning(move || COLLECTION_URL.into()); + api_client.expect_is_prod_server().returning(|| Ok(true)); + let changeset = ChangesetResponse { + changes: vec![], + timestamp: 1603992731957, + metadata: CollectionMetadata { + bucket: "main".into(), + signature: CollectionSignature { + signature: VALID_SIGNATURE.into(), + x5u: "http://mocked".into(), + }, + }, + }; + api_client + .expect_fetch_changeset() + .withf(|timestamp| timestamp.is_none()) + .returning(move |_| Ok(changeset.clone())); + api_client + .expect_fetch_cert() + .returning(move |_| Ok(VALID_CERTIFICATE.as_bytes().to_vec())); + + let rs_client = + RemoteSettingsClient::new_from_parts(COLLECTION_NAME.to_string(), storage, api_client); + + let err = rs_client.sync().unwrap_err(); + + assert!( + matches!(err, Error::SignatureError(_)), + "Signature could not be verified: Certificate subject mismatch" + ); + + Ok(()) + } + + #[test] + fn test_invalid_signature_invalid_data() -> Result<()> { + // Adjust current time, since the above hard-coded certificate has expired + // since then. + let march_12_2021 = Duration::from_secs(1615559719); + MockClock::set_time(march_12_2021); + + let mut api_client = MockApiClient::new(); + let storage = Storage::new(":memory:".into())?; + + api_client + .expect_collection_url() + .returning(move || COLLECTION_URL.into()); + api_client.expect_is_prod_server().returning(|| Ok(true)); + let changeset = ChangesetResponse { + changes: vec![RemoteSettingsRecord { + id: "unexpected-data".to_string(), + last_modified: 42, + deleted: false, + attachment: None, + fields: serde_json::Map::new(), + }], + timestamp: 1603992731957, + metadata: CollectionMetadata { + bucket: "main".into(), + signature: CollectionSignature { + signature: VALID_SIGNATURE.into(), + x5u: "http://mocked".into(), + }, + }, + }; + api_client + .expect_fetch_changeset() + .withf(|timestamp| timestamp.is_none()) + .returning(move |_| Ok(changeset.clone())); + api_client + .expect_fetch_cert() + .returning(move |_| Ok(VALID_CERTIFICATE.as_bytes().to_vec())); + + let rs_client = + RemoteSettingsClient::new_from_parts(COLLECTION_NAME.to_string(), storage, api_client); + + let err = rs_client.sync().unwrap_err(); + + assert!( + matches!(err, Error::SignatureError(_)), + "Signature could not be verified: Content signature mismatch error: NSS error: NSS error: -8182" + ); + + Ok(()) + } + + #[test] + fn test_invalid_signature_invalid_signer_name() -> Result<()> { + // Adjust current time, since the above hard-coded certificate has expired + // since then. + let march_12_2021 = Duration::from_secs(1615559719); + MockClock::set_time(march_12_2021); + + let mut api_client = MockApiClient::new(); + let storage = Storage::new(":memory:".into())?; + + api_client + .expect_collection_url() + .returning(move || COLLECTION_URL.into()); + api_client.expect_is_prod_server().returning(|| Ok(true)); + let changeset = ChangesetResponse { + changes: vec![], + timestamp: 1603992731957, + metadata: CollectionMetadata { + bucket: "security-state".into(), + signature: CollectionSignature { + signature: VALID_CERTIFICATE.into(), + x5u: "http://mocked".into(), + }, + }, + }; + api_client + .expect_fetch_changeset() + .withf(|timestamp| timestamp.is_none()) + .returning(move |_| Ok(changeset.clone())); + api_client + .expect_fetch_cert() + .returning(move |_| Ok(VALID_CERTIFICATE.as_bytes().to_vec())); + + let rs_client = + RemoteSettingsClient::new_from_parts(COLLECTION_NAME.to_string(), storage, api_client); + + let err = rs_client.sync().unwrap_err(); + + assert!( + matches!(err, Error::SignatureError(_)), + "Signature could not be verified: Certificate subject mismatch" + ); + + Ok(()) + } +} diff --git a/components/remote_settings/src/error.rs b/components/remote_settings/src/error.rs index 6c056e9add..c958aa32e3 100644 --- a/components/remote_settings/src/error.rs +++ b/components/remote_settings/src/error.rs @@ -49,8 +49,14 @@ pub enum Error { DatabaseError(#[from] rusqlite::Error), #[error("No attachment in given record: {0}")] RecordAttachmentMismatchError(String), - #[error("data could not be serialized: {0}")] + #[error("Incomplete signature data: {0}")] + IncompleteSignatureDataError(String), + #[cfg(feature = "signatures")] + #[error("Data could not be serialized: {0}")] SerializationError(#[from] canonical_json::CanonicalJSONError), + #[cfg(feature = "signatures")] + #[error("Signature could not be verified: {0}")] + SignatureError(#[from] rc_crypto::Error), } // Define how our internal errors are handled and converted to external errors diff --git a/components/remote_settings/src/lib.rs b/components/remote_settings/src/lib.rs index 9629bac5e4..5a8348e1e2 100644 --- a/components/remote_settings/src/lib.rs +++ b/components/remote_settings/src/lib.rs @@ -14,7 +14,8 @@ pub mod client; pub mod config; pub mod error; pub mod service; -pub mod signatures; +#[cfg(feature = "signatures")] +pub(crate) mod signatures; pub mod storage; #[cfg(feature = "jexl")] diff --git a/components/remote_settings/src/signatures.rs b/components/remote_settings/src/signatures.rs index d81e453c1c..b40c87ef4d 100644 --- a/components/remote_settings/src/signatures.rs +++ b/components/remote_settings/src/signatures.rs @@ -1,14 +1,11 @@ - use core::clone::Clone; -use crate::{ - RemoteSettingsRecord, - Result, -}; +use crate::{RemoteSettingsRecord, Result}; use canonical_json; +use rc_crypto::contentsignature; use serde_json::{json, Value}; - +/// Remove `deleted` and `attachment` fields if it is null. fn select_record_fields(value: &Value) -> Value { if let Value::Object(map) = value { let new_map = map @@ -27,6 +24,7 @@ fn select_record_fields(value: &Value) -> Value { } } +/// Serialize collection data into canonical JSON. This must match the server implementation. fn serialize_data(timestamp: u64, records: Vec) -> Result> { let mut sorted_records = records.to_vec(); sorted_records.sort_by_cached_key(|r| r.id.clone()); @@ -38,28 +36,73 @@ fn serialize_data(timestamp: u64, records: Vec) -> Result< Ok(data.as_bytes().to_vec()) } +/// Verify that the timestamp and records match the signature in the metadata. +pub fn verify_signature( + subject_cname: String, + cert_chain_bytes: Vec, + signature: Vec, + timestamp: u64, + records: Vec, + epoch_seconds: u64, +) -> Result<()> { + // TODO + const ROOT_HASH: &str = "3C:01:44:6A:BE:90:36:CE:A9:A0:9A:CA:A3:A5:20:AC:62:8F:20:A7:AE:32:CE:86:1C:B2:EF:B7:0F:A0:C7:45"; + let message = serialize_data(timestamp, records)?; + // Check that certificate chain is valid at specific date time, and + // that signature matches the input message. + contentsignature::verify( + &message, + &signature, + &cert_chain_bytes, + epoch_seconds, + ROOT_HASH, + &subject_cname, + )?; + Ok(()) +} + #[cfg(test)] mod tests { - use crate::{RemoteSettingsRecord, Attachment}; - use serde_json::json; use super::serialize_data; + use crate::{Attachment, RemoteSettingsRecord}; + use serde_json::json; #[test] fn test_records_canonicaljson_serialization() { - let bytes = serialize_data(1337, vec![RemoteSettingsRecord{last_modified: 42, id: "bonjour".into(), deleted: false, attachment: None, fields: json!({"foo": "bar"}).as_object().unwrap().clone()}]).unwrap(); + let bytes = serialize_data( + 1337, + vec![RemoteSettingsRecord { + last_modified: 42, + id: "bonjour".into(), + deleted: false, + attachment: None, + fields: json!({"foo": "bar"}).as_object().unwrap().clone(), + }], + ) + .unwrap(); let s = String::from_utf8(bytes).unwrap(); assert_eq!(s, "Content-Signature:\u{0}{\"data\":[{\"id\":\"bonjour\",\"last_modified\":42,\"foo\":\"bar\"}],\"last_modified\":\"1337\"}"); } #[test] fn test_records_canonicaljson_serialization_with_attachment() { - let bytes = serialize_data(1337, vec![RemoteSettingsRecord{last_modified: 42, id: "bonjour".into(), deleted: true, attachment: Some(Attachment { - filename: "pix.jpg".into(), - mimetype: "image/jpeg".into(), - location: "folder/file.jpg".into(), - hash: "aabbcc".into(), - size: 1234567, - }), fields: json!({}).as_object().unwrap().clone()}]).unwrap(); + let bytes = serialize_data( + 1337, + vec![RemoteSettingsRecord { + last_modified: 42, + id: "bonjour".into(), + deleted: true, + attachment: Some(Attachment { + filename: "pix.jpg".into(), + mimetype: "image/jpeg".into(), + location: "folder/file.jpg".into(), + hash: "aabbcc".into(), + size: 1234567, + }), + fields: json!({}).as_object().unwrap().clone(), + }], + ) + .unwrap(); let s = String::from_utf8(bytes).unwrap(); assert_eq!(s, "Content-Signature:\0{\"data\":[{\"id\":\"bonjour\",\"last_modified\":42,\"attachment\":{\"filename\":\"pix.jpg\",\"mimetype\":\"image/jpeg\",\"location\":\"folder/file.jpg\",\"hash\":\"aabbcc\",\"size\":1234567}}],\"last_modified\":\"1337\"}"); }