From 53c80d2437a857d516f916b59ea509e4203b54f7 Mon Sep 17 00:00:00 2001 From: Ted Campbell Date: Thu, 27 Jul 2023 22:30:32 -0400 Subject: [PATCH] Add Oblivious HTTP (OHTTP) client for iOS Using the ohttp-rs and bhttp-rs Rust libraries to perform underlying encoding and encryption. This is exposed to Swift using URLRequest/Response types that are typical for the platform. Note that only iOS is targetted since Gecko projects have a builtin OHTTP solution within Necko. --- Cargo.lock | 288 +++++++++++++++- Cargo.toml | 2 + components/as-ohttp-client/Cargo.toml | 18 + components/as-ohttp-client/build.rs | 7 + .../ios/ASOhttpClient/OhttpManager.swift | 107 ++++++ .../as-ohttp-client/src/as_ohttp_client.udl | 69 ++++ components/as-ohttp-client/src/lib.rs | 307 +++++++++++++++++ components/as-ohttp-client/uniffi.toml | 3 + megazords/ios-rust/Cargo.toml | 1 + megazords/ios-rust/MozillaRustComponents.h | 1 + .../project.pbxproj | 33 ++ .../MozillaTestServicesTests/OhttpTests.swift | 316 ++++++++++++++++++ megazords/ios-rust/build-xcframework.sh | 1 + megazords/ios-rust/src/lib.rs | 1 + 14 files changed, 1150 insertions(+), 4 deletions(-) create mode 100644 components/as-ohttp-client/Cargo.toml create mode 100644 components/as-ohttp-client/build.rs create mode 100644 components/as-ohttp-client/ios/ASOhttpClient/OhttpManager.swift create mode 100644 components/as-ohttp-client/src/as_ohttp_client.udl create mode 100644 components/as-ohttp-client/src/lib.rs create mode 100644 components/as-ohttp-client/uniffi.toml create mode 100644 megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/OhttpTests.swift diff --git a/Cargo.lock b/Cargo.lock index f23a0a9f74..b4c60ce37d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,42 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b613b8e1e3cf911a086f53f03bf286f52fd7a7258e4fa606f0ef220d39d8877" +dependencies = [ + "generic-array", + "rand_core 0.6.3", +] + +[[package]] +name = "aes" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e8b47f52ea9bae42228d07ec09eb676433d7c4ed1ebdf0f1d1c29ed446f1ab8" +dependencies = [ + "cfg-if 1.0.0", + "cipher", + "cpufeatures 0.2.2", + "opaque-debug", +] + +[[package]] +name = "aes-gcm" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df5f85a83a7d8b0442b6aa7b504b8212c1733da07b98aae43d4bc21b2cb3cdf6" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.7.6" @@ -138,6 +174,16 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "as-ohttp-client" +version = "0.1.0" +dependencies = [ + "bhttp", + "ohttp", + "thiserror", + "uniffi", +] + [[package]] name = "askama" version = "0.10.5" @@ -324,6 +370,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bhttp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1300dca7a20730cce82c33fbf8795862556645ef5e9ee835390278c3fe1eb1d0" +dependencies = [ + "thiserror", +] + [[package]] name = "bincode" version = "1.3.3" @@ -498,6 +553,31 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chacha20" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fee7ad89dc1128635074c268ee661f90c3f7e83d9fd12910608c36b47d6c3412" +dependencies = [ + "cfg-if 1.0.0", + "cipher", + "cpufeatures 0.1.5", + "zeroize", +] + +[[package]] +name = "chacha20poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1580317203210c517b6d44794abfbe600698276db18127e37ad3e69bf5e848e5" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.26" @@ -514,6 +594,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + [[package]] name = "cirrus" version = "0.1.0" @@ -676,6 +765,15 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" +[[package]] +name = "cpufeatures" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.2.2" @@ -784,6 +882,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "crypto-mac" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "csv" version = "1.1.6" @@ -827,6 +935,15 @@ dependencies = [ "syn 1.0.98", ] +[[package]] +name = "ctr" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "049bb91fb4aaf0e3c7efa6cd5ef877dbbbd15b39dad06d9948de4ec8a75761ea" +dependencies = [ + "cipher", +] + [[package]] name = "ctrlc" version = "3.2.2" @@ -837,6 +954,19 @@ dependencies = [ "winapi", ] +[[package]] +name = "curve25519-dalek" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61" +dependencies = [ + "byteorder", + "digest", + "rand_core 0.5.1", + "subtle", + "zeroize", +] + [[package]] name = "cxx" version = "1.0.92" @@ -1557,6 +1687,16 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "ghash" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1583cc1656d7839fd3732b80cf4f38850336cdb9b8ded1cd399ca62958de3c99" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.26.1" @@ -1680,6 +1820,46 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01706d578d5c281058480e673ae4086a9f4710d8df1ad80a5b03e39ece5f886b" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "hmac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +dependencies = [ + "crypto-mac", + "digest", +] + +[[package]] +name = "hpke" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b27779b5c326e3afe887e806ab04ac34922a9a723ee3fae62170b3f7ad33380" +dependencies = [ + "aead", + "aes-gcm", + "byteorder", + "chacha20poly1305", + "digest", + "generic-array", + "hkdf", + "rand_core 0.6.3", + "sha2", + "subtle", + "x25519-dalek", + "zeroize", +] + [[package]] name = "http" version = "0.2.8" @@ -2180,6 +2360,7 @@ dependencies = [ name = "megazord_ios" version = "0.1.0" dependencies = [ + "as-ohttp-client", "autofill", "crashtest", "error-support", @@ -2677,6 +2858,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "ohttp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "850ce328ec7e4dc1a9446c56aef700d21d914268c8529b96017a2bf10f74b70f" +dependencies = [ + "aead", + "aes-gcm", + "byteorder", + "chacha20poly1305", + "hex", + "hkdf", + "hpke", + "lazy_static", + "log", + "rand", + "serde", + "serde_derive", + "sha2", + "thiserror", + "toml", +] + [[package]] name = "once_cell" version = "1.13.0" @@ -2934,6 +3138,29 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "poly1305" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048aeb476be11a4b6ca432ca569e375810de9294ae78f4774e78ea98a9246ede" +dependencies = [ + "cpufeatures 0.2.2", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8419d2b623c7c0896ff2d5d96e2cb4ede590fed28fcc34934f4c33c036e620a1" +dependencies = [ + "cfg-if 1.0.0", + "cpufeatures 0.2.2", + "opaque-debug", + "universal-hash", +] + [[package]] name = "ppv-lite86" version = "0.2.16" @@ -3160,7 +3387,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.3", ] [[package]] @@ -3170,9 +3397,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.3", ] +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" + [[package]] name = "rand_core" version = "0.6.3" @@ -3187,7 +3420,7 @@ name = "rand_rccrypto" version = "0.1.0" dependencies = [ "rand", - "rand_core", + "rand_core 0.6.3", "rc_crypto", ] @@ -3678,7 +3911,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer", "cfg-if 1.0.0", - "cpufeatures", + "cpufeatures 0.2.2", "digest", "opaque-debug", ] @@ -3815,6 +4048,12 @@ dependencies = [ "syn 1.0.98", ] +[[package]] +name = "subtle" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" + [[package]] name = "suggest" version = "0.1.0" @@ -4403,6 +4642,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "universal-hash" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f214e8f697e925001e66ec2c6e37a4ef93f0f78c2eed7814394e10c62025b05" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.8" @@ -5012,6 +5261,17 @@ dependencies = [ "nix", ] +[[package]] +name = "x25519-dalek" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a0c105152107e3b96f6a00a65e86ce82d9b125230e1c4302940eca58ff71f4f" +dependencies = [ + "curve25519-dalek", + "rand_core 0.5.1", + "zeroize", +] + [[package]] name = "xcursor" version = "0.3.4" @@ -5065,3 +5325,23 @@ checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" dependencies = [ "linked-hash-map", ] + +[[package]] +name = "zeroize" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] diff --git a/Cargo.toml b/Cargo.toml index 8fd7e1c3f3..fe01f0213a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] # Note: Any additions here should be repeated in default-members below. members = [ + "components/as-ohttp-client", "components/autofill", "components/crashtest", "components/fxa-client", @@ -77,6 +78,7 @@ exclude = [ # To be clear: passing the `--all` or `--workspace` arg to cargo will make it # use the full member set. default-members = [ + "components/as-ohttp-client", "components/autofill", "components/crashtest", "components/fxa-client", diff --git a/components/as-ohttp-client/Cargo.toml b/components/as-ohttp-client/Cargo.toml new file mode 100644 index 0000000000..449eb9f693 --- /dev/null +++ b/components/as-ohttp-client/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "as-ohttp-client" +version = "0.1.0" +edition = "2021" +authors = ["Ted Campbell "] +description = "An Oblivious HTTP client for iOS applications" +license = "MPL-2.0" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +uniffi = "0.24.1" +thiserror = "1.0" +bhttp = "0.3" +ohttp = { version = "0.3", default-features = false, features = ["client", "server", "rust-hpke"]} + +[build-dependencies] +uniffi = { version = "0.24.1", features=["build"]} diff --git a/components/as-ohttp-client/build.rs b/components/as-ohttp-client/build.rs new file mode 100644 index 0000000000..a849381fd0 --- /dev/null +++ b/components/as-ohttp-client/build.rs @@ -0,0 +1,7 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +fn main() { + uniffi::generate_scaffolding("./src/as_ohttp_client.udl").unwrap(); +} diff --git a/components/as-ohttp-client/ios/ASOhttpClient/OhttpManager.swift b/components/as-ohttp-client/ios/ASOhttpClient/OhttpManager.swift new file mode 100644 index 0000000000..c478442826 --- /dev/null +++ b/components/as-ohttp-client/ios/ASOhttpClient/OhttpManager.swift @@ -0,0 +1,107 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import Foundation + +class OhttpManager { + // The OhttpManager communicates with the relay and key server using + // URLSession.shared.data unless an alternative networking method is + // provided with this signature. + typealias NetworkFunction = (_: URLRequest) async throws -> (Data, URLResponse) + + // Global cache to caching Gateway encryption keys. Stale entries are + // ignored and on Gateway errors the key used should be purged and retrieved + // again next at next network attempt. + static var keyCache = [URL: ([UInt8], Date)]() + + private var configUrl: String + private var relayUrl: String + private var network: NetworkFunction + + init(configUrl: String, + relayUrl: String, + network: @escaping NetworkFunction = URLSession.shared.data) + { + self.configUrl = configUrl + self.relayUrl = relayUrl + self.network = network + } + + private func fetchKey(url: URL) async throws -> [UInt8] { + let request = URLRequest(url: url) + if let (data, response) = try? await network(request) { + if (response as! HTTPURLResponse).statusCode == 200 { + return [UInt8](data) + } + } + + throw OhttpError.KeyFetchFailed(message: "Failed to fetch encryption key") + } + + private func keyForGateway(gatewayConfigUrl: URL, ttl: TimeInterval) async throws -> [UInt8] { + if let (data, timestamp) = Self.keyCache[gatewayConfigUrl] { + if Date.now < timestamp + ttl { + // Cache Hit! + return data + } + + Self.keyCache.removeValue(forKey: gatewayConfigUrl) + } + + let data = try await fetchKey(url: gatewayConfigUrl) + Self.keyCache[gatewayConfigUrl] = (data, Date.now) + + return data + } + + private func invalidateKey() { + Self.keyCache.removeValue(forKey: URL(string: configUrl)!) + } + + func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) { + // Get the encryption keys for Gateway + let config = try await keyForGateway(gatewayConfigUrl: URL(string: configUrl)!, + ttl: TimeInterval(3600)) + + // Create an encryption session for a request-response round-trip + let session = try OhttpSession(config: config) + + // Encapsulate the URLRequest for the Target + let encoded = try session.encapsulate(method: request.httpMethod ?? "GET", + scheme: request.url!.scheme!, + server: request.url!.host!, + endpoint: request.url!.path, + headers: request.allHTTPHeaderFields ?? [:], + payload: [UInt8](request.httpBody ?? Data())) + + // Request from Client to Relay + var request = URLRequest(url: URL(string: relayUrl)!) + request.httpMethod = "POST" + request.setValue("message/ohttp-req", forHTTPHeaderField: "Content-Type") + request.httpBody = Data(encoded) + + let (data, response) = try await network(request) + let httpResponse = response as! HTTPURLResponse + + // Decapsulation failures have these codes, so invalidate any cached + // keys in case the gateway has changed them. + if httpResponse.statusCode == 400 || + httpResponse.statusCode == 401 + { + invalidateKey() + } + + guard httpResponse.statusCode == 200 else { + throw OhttpError.RelayFailed(message: "Network errors communicating with Relay / Gateway") + } + + // Decapsulate the Target response into a HTTPURLResponse + let message = try session.decapsulate(encoded: [UInt8](data)) + return (Data(message.payload), + HTTPURLResponse(url: request.url!, + statusCode: Int(message.statusCode), + httpVersion: "HTTP/1.1", + headerFields: message.headers)!) + } +} diff --git a/components/as-ohttp-client/src/as_ohttp_client.udl b/components/as-ohttp-client/src/as_ohttp_client.udl new file mode 100644 index 0000000000..c169e5958e --- /dev/null +++ b/components/as-ohttp-client/src/as_ohttp_client.udl @@ -0,0 +1,69 @@ +namespace as_ohttp_client { +}; + +[Error] +enum OhttpError { + "KeyFetchFailed", + "MalformedKeyConfig", + "UnsupportedKeyConfig", + "InvalidSession", + "RelayFailed", + "CannotEncodeMessage", + "MalformedMessage", + "DuplicateHeaders", +}; + +// The decrypted response from the Gateway/Target +dictionary OhttpResponse { + u16 status_code; + record headers; + sequence payload; +}; + +// Each OHTTP request-reply exchange needs to create an OhttpSession +// object to manage encryption state. +interface OhttpSession { + // Initialize encryption state based on specific Gateway key config + [Throws=OhttpError] + constructor([ByRef] sequence config); + + // Encapsulate an HTTP request as Binary HTTP and then encrypt that + // payload using HPKE. The caller is reponsible for sending the + // resulting message to the Relay. + [Throws=OhttpError] + sequence encapsulate([ByRef] string method, + [ByRef] string scheme, + [ByRef] string server, + [ByRef] string endpoint, + record headers, + [ByRef] sequence payload); + + // Decypt and unpack the response from the Relay for the previously + // encapsulated request. You must use the same OhttpSession that + // generated the request message. + [Throws=OhttpError] + OhttpResponse decapsulate([ByRef] sequence encoded); +}; + +dictionary TestServerRequest { + string method; + string scheme; + string server; + string endpoint; + record headers; + sequence payload; +}; + +// A testing interface for decrypting and responding to OHTTP messages. This +// will on malformed data and should only be used for testing. +interface OhttpTestServer { + constructor(); + + sequence get_config(); + + [Throws=OhttpError] + TestServerRequest receive([ByRef] sequence message); + + [Throws=OhttpError] + sequence respond(OhttpResponse response); +}; diff --git a/components/as-ohttp-client/src/lib.rs b/components/as-ohttp-client/src/lib.rs new file mode 100644 index 0000000000..81927b4783 --- /dev/null +++ b/components/as-ohttp-client/src/lib.rs @@ -0,0 +1,307 @@ +extern crate bhttp; +extern crate ohttp; + +use std::collections::HashMap; +use std::sync::Mutex; + +#[derive(Debug, thiserror::Error)] +pub enum OhttpError { + #[error("Failed to fetch encryption key")] + KeyFetchFailed, + + #[error("OHTTP key config is malformed")] + MalformedKeyConfig, + + #[error("Unsupported OHTTP encryption algorithm")] + UnsupportedKeyConfig, + + #[error("OhttpSession is in invalid state")] + InvalidSession, + + #[error("Network errors communicating with Relay / Gateway")] + RelayFailed, + + #[error("Cannot encode message as BHTTP/OHTTP")] + CannotEncodeMessage, + + #[error("Cannot decode OHTTP/BHTTP message")] + MalformedMessage, + + #[error("Duplicate HTTP response headers")] + DuplicateHeaders, +} + +enum ExchangeState { + Invalid, + Request(ohttp::ClientRequest), + Response(ohttp::ClientResponse), +} + +impl Default for ExchangeState { + fn default() -> Self { + ExchangeState::Invalid + } +} + +pub struct OhttpSession { + state: Mutex, +} + +pub struct OhttpResponse { + status_code: u16, + headers: HashMap, + payload: Vec, +} + +/// Transform the headers from a BHTTP message into a HashMap for use from Swift +/// later. If there are duplicate errors, we currently raise an error. +fn headers_to_map(message: &bhttp::Message) -> Result, OhttpError> { + let mut headers = HashMap::new(); + + for field in message.header().iter() { + if let Some(_) = headers.insert( + std::str::from_utf8(field.name()) + .map_err(|_| OhttpError::MalformedMessage)? + .into(), + std::str::from_utf8(field.value()) + .map_err(|_| OhttpError::MalformedMessage)? + .into(), + ) { + return Err(OhttpError::DuplicateHeaders); + } + } + + Ok(headers) +} + +impl OhttpSession { + /// Create a new encryption session for use with specific key configuration + pub fn new(config: &[u8]) -> Result { + let request = ohttp::ClientRequest::new(&config).map_err(|e| match e { + ohttp::Error::Unsupported => OhttpError::UnsupportedKeyConfig, + _ => OhttpError::MalformedKeyConfig, + })?; + + let state = Mutex::new(ExchangeState::Request(request)); + Ok(OhttpSession { state }) + } + + /// Encode an HTTP request in Binary HTTP format and then encrypt it into an + /// Oblivious HTTP request message. + pub fn encapsulate( + &self, + method: &str, + scheme: &str, + server: &str, + endpoint: &str, + mut headers: HashMap, + payload: &[u8], + ) -> Result, OhttpError> { + let mut message = + bhttp::Message::request(method.into(), scheme.into(), server.into(), endpoint.into()); + + for (k, v) in headers.drain() { + message.put_header(k, v); + } + + message.write_content(&payload); + + let mut encoded = vec![]; + message + .write_bhttp(bhttp::Mode::KnownLength, &mut encoded) + .map_err(|_| OhttpError::CannotEncodeMessage)?; + + let mut state = self.state.lock().map_err(|_| OhttpError::InvalidSession)?; + let request = match std::mem::take(&mut *state) { + ExchangeState::Request(request) => request, + _ => return Err(OhttpError::InvalidSession), + }; + let (capsule, response) = request + .encapsulate(&encoded) + .map_err(|_| OhttpError::CannotEncodeMessage)?; + *state = ExchangeState::Response(response); + + Ok(capsule) + } + + /// Decode an OHTTP response returned in response to a request encoded on + /// this session. + pub fn decapsulate(&self, encoded: &[u8]) -> Result { + let mut state = self.state.lock().unwrap(); + let decoder = match std::mem::take(&mut *state) { + ExchangeState::Response(response) => response, + _ => return Err(OhttpError::InvalidSession), + }; + let binary = decoder + .decapsulate(&encoded) + .map_err(|_| OhttpError::MalformedMessage)?; + + let mut cursor = std::io::Cursor::new(binary); + let message = + bhttp::Message::read_bhttp(&mut cursor).map_err(|_| OhttpError::MalformedMessage)?; + + let headers = headers_to_map(&message)?; + + Ok(OhttpResponse { + status_code: match message.control() { + bhttp::ControlData::Response(sc) => *sc, + _ => return Err(OhttpError::InvalidSession), + }, + headers: headers, + payload: message.content().into(), + }) + } +} + +pub struct OhttpTestServer { + server: Mutex, + state: Mutex>, + config: Vec, +} + +pub struct TestServerRequest { + method: String, + scheme: String, + server: String, + endpoint: String, + headers: HashMap, + payload: Vec, +} + +impl OhttpTestServer { + /// Create a simple OHTTP server to decrypt and respond to OHTTP messages in + /// testing. The key is randomly generated. + fn new() -> Self { + let key = ohttp::KeyConfig::new( + 0x01, + ohttp::hpke::Kem::X25519Sha256, + vec![ohttp::SymmetricSuite::new( + ohttp::hpke::Kdf::HkdfSha256, + ohttp::hpke::Aead::Aes128Gcm, + )], + ) + .unwrap(); + + let config = key.encode().unwrap(); + let server = ohttp::Server::new(key).unwrap(); + + OhttpTestServer { + server: Mutex::new(server), + state: Mutex::new(Option::None), + config: config, + } + } + + /// Return a copy of the key config for clients to use. + fn get_config(&self) -> Vec { + self.config.clone() + } + + /// Decode an OHTTP request message and return the cleartext contents. This + /// also updates the internal server state so that a response message can be + /// generated. + fn receive(&self, message: &[u8]) -> Result { + let (encoded, response) = self + .server + .lock() + .unwrap() + .decapsulate(&message) + .map_err(|_| OhttpError::MalformedMessage)?; + let mut cursor = std::io::Cursor::new(encoded); + let message = + bhttp::Message::read_bhttp(&mut cursor).map_err(|_| OhttpError::MalformedMessage)?; + + *self.state.lock().unwrap() = Some(response); + + let headers = headers_to_map(&message)?; + + match message.control() { + bhttp::ControlData::Request { + method, + scheme, + authority, + path, + } => Ok(TestServerRequest { + method: String::from_utf8_lossy(method).into(), + scheme: String::from_utf8_lossy(scheme).into(), + server: String::from_utf8_lossy(authority).into(), + endpoint: String::from_utf8_lossy(path).into(), + headers: headers, + payload: message.content().into(), + }), + _ => Err(OhttpError::MalformedMessage), + } + } + + /// Encode an OHTTP response keyed to the last message received. + fn respond(&self, response: OhttpResponse) -> Result, OhttpError> { + let state = self.state.lock().unwrap().take().unwrap(); + + let mut message = bhttp::Message::response(response.status_code); + message.write_content(&response.payload); + + for (k, v) in response.headers { + message.put_header(k, v); + } + + let mut encoded = vec![]; + message + .write_bhttp(bhttp::Mode::KnownLength, &mut encoded) + .map_err(|_| OhttpError::CannotEncodeMessage)?; + + state + .encapsulate(&encoded) + .map_err(|_| OhttpError::CannotEncodeMessage) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_smoke() { + let server = OhttpTestServer::new(); + let config = server.get_config(); + + let body: Vec = vec![0x00, 0x01, 0x02]; + let header = HashMap::from([ + ("Content-Type".into(), "application/octet-stream".into()), + ("X-Header".into(), "value".into()), + ]); + + let session = OhttpSession::new(&config).unwrap(); + let mut message = session + .encapsulate( + "GET".into(), + "https".into(), + "example.com".into(), + "/api".into(), + header.clone(), + &body, + ) + .unwrap(); + + let request = server.receive(&message).unwrap(); + assert_eq!(request.method, "GET"); + assert_eq!(request.scheme, "https"); + assert_eq!(request.server, "example.com"); + assert_eq!(request.endpoint, "/api"); + assert_eq!(request.headers, header); + + message = server + .respond(OhttpResponse { + status_code: 200, + headers: header.clone(), + payload: body.clone(), + }) + .unwrap(); + + let response = session.decapsulate(&message).unwrap(); + assert_eq!(response.status_code, 200); + assert_eq!(response.headers, header); + assert_eq!(response.payload, body); + } +} + +uniffi::include_scaffolding!("as_ohttp_client"); diff --git a/components/as-ohttp-client/uniffi.toml b/components/as-ohttp-client/uniffi.toml new file mode 100644 index 0000000000..eddc1e4681 --- /dev/null +++ b/components/as-ohttp-client/uniffi.toml @@ -0,0 +1,3 @@ +[bindings.swift] +ffi_module_name = "MozillaRustComponents" +ffi_module_filename = "as_ohttp_clientFFI" diff --git a/megazords/ios-rust/Cargo.toml b/megazords/ios-rust/Cargo.toml index 395ad39e39..4df9262bee 100644 --- a/megazords/ios-rust/Cargo.toml +++ b/megazords/ios-rust/Cargo.toml @@ -25,3 +25,4 @@ remote_settings = { path = "../../components/remote_settings" } sync15 = {path = "../../components/sync15"} error-support = { path = "../../components/support/error" } sync_manager = { path = "../../components/sync_manager" } +as-ohttp-client = { path = "../../components/as-ohttp-client" } diff --git a/megazords/ios-rust/MozillaRustComponents.h b/megazords/ios-rust/MozillaRustComponents.h index bbb98dc81d..110b428fa1 100644 --- a/megazords/ios-rust/MozillaRustComponents.h +++ b/megazords/ios-rust/MozillaRustComponents.h @@ -19,3 +19,4 @@ #import "errorFFI.h" #import "syncmanagerFFI.h" #import "remote_settingsFFI.h" +#import "as_ohttp_clientFFI.h" diff --git a/megazords/ios-rust/MozillaTestServices/MozillaTestServices.xcodeproj/project.pbxproj b/megazords/ios-rust/MozillaTestServices/MozillaTestServices.xcodeproj/project.pbxproj index 12f0cb40df..69776dd0e5 100644 --- a/megazords/ios-rust/MozillaTestServices/MozillaTestServices.xcodeproj/project.pbxproj +++ b/megazords/ios-rust/MozillaTestServices/MozillaTestServices.xcodeproj/project.pbxproj @@ -71,6 +71,9 @@ 45CC574A28AD9C86006D55AA /* errorsupport.udl in Sources */ = {isa = PBXBuildFile; fileRef = 45CC574828AD9C31006D55AA /* errorsupport.udl */; }; AB9C392E2A0DA61900AF5ADE /* remote_settings.udl in Sources */ = {isa = PBXBuildFile; fileRef = AB65933B2A0C524E00DBF059 /* remote_settings.udl */; }; F40A9DCD29765EB20033D10E /* sync15.udl in Sources */ = {isa = PBXBuildFile; fileRef = F40A9DCB29765DCB0033D10E /* sync15.udl */; }; + F54D38102A5862E4005087FB /* OhttpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F54D380F2A5862E4005087FB /* OhttpTests.swift */; }; + F54D38112A5E09F4005087FB /* as_ohttp_client.udl in Sources */ = {isa = PBXBuildFile; fileRef = F54D380C2A5732A3005087FB /* as_ohttp_client.udl */; }; + F596D2E02A68922100C8A817 /* OhttpManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F596D2DF2A68922100C8A817 /* OhttpManager.swift */; }; F814DE8029DF762800FD26F5 /* SyncManagerTelemetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F814DE7F29DF762800FD26F5 /* SyncManagerTelemetryTests.swift */; }; F81C7B9829DE305C00FAF8F9 /* SyncManagerTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = F81C7B9729DE305C00FAF8F9 /* SyncManagerTelemetry.swift */; }; F81C7B9A29DE309C00FAF8F9 /* SyncManagerComponent.swift in Sources */ = {isa = PBXBuildFile; fileRef = F81C7B9929DE309C00FAF8F9 /* SyncManagerComponent.swift */; }; @@ -206,6 +209,11 @@ F40A9DCB29765DCB0033D10E /* sync15.udl */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = sync15.udl; path = ../../../components/sync15/src/sync15.udl; sourceTree = SOURCE_ROOT; }; F4FCED2E2976605400BA127E /* sync15FFI.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = sync15FFI.h; path = ../../../components/sync15/ios/Generated/sync15FFI.h; sourceTree = SOURCE_ROOT; }; F4FCED2F2976605400BA127E /* sync15.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = sync15.swift; path = ../../../components/sync15/ios/Generated/sync15.swift; sourceTree = SOURCE_ROOT; }; + F54D38072A564EC9005087FB /* as_ohttp_client.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = as_ohttp_client.swift; path = ../../ios/Generated/as_ohttp_client.swift; sourceTree = ""; }; + F54D38082A564EC9005087FB /* as_ohttp_clientFFI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = as_ohttp_clientFFI.h; path = ../../ios/Generated/as_ohttp_clientFFI.h; sourceTree = ""; }; + F54D380C2A5732A3005087FB /* as_ohttp_client.udl */ = {isa = PBXFileReference; lastKnownFileType = text; name = as_ohttp_client.udl; path = ../src/as_ohttp_client.udl; sourceTree = ""; }; + F54D380F2A5862E4005087FB /* OhttpTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OhttpTests.swift; sourceTree = ""; }; + F596D2DF2A68922100C8A817 /* OhttpManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OhttpManager.swift; path = ASOhttpClient/OhttpManager.swift; sourceTree = ""; }; F814DE7F29DF762800FD26F5 /* SyncManagerTelemetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncManagerTelemetryTests.swift; sourceTree = ""; }; F81C7B9729DE305C00FAF8F9 /* SyncManagerTelemetry.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SyncManagerTelemetry.swift; path = ../../../components/sync_manager/ios/SyncManager/SyncManagerTelemetry.swift; sourceTree = SOURCE_ROOT; }; F81C7B9929DE309C00FAF8F9 /* SyncManagerComponent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SyncManagerComponent.swift; path = ../../../components/sync_manager/ios/SyncManager/SyncManagerComponent.swift; sourceTree = SOURCE_ROOT; }; @@ -337,6 +345,7 @@ 1BBAC4FA27AE049500DAFEF2 /* MozillaTestServices */ = { isa = PBXGroup; children = ( + F54D38032A564653005087FB /* ASOhttpClient */, AB65933A2A0C51F300DBF059 /* RemoteSettings */, F8AAC1CB298B40B8000BCDEC /* SyncManager */, 15DAE2082944142000DB06FE /* Autofill */, @@ -372,6 +381,7 @@ F85ED648299C1F49005EEF36 /* RustSyncTelemetryPingTests.swift */, F814DE7F29DF762800FD26F5 /* SyncManagerTelemetryTests.swift */, 39EE00FE29F6DBBA001E7758 /* NimbusArgumentProcessorTests.swift */, + F54D380F2A5862E4005087FB /* OhttpTests.swift */, ); path = MozillaTestServicesTests; sourceTree = SOURCE_ROOT; @@ -586,6 +596,26 @@ path = Generated; sourceTree = ""; }; + F54D38032A564653005087FB /* ASOhttpClient */ = { + isa = PBXGroup; + children = ( + F54D38042A564653005087FB /* Generated */, + F54D380C2A5732A3005087FB /* as_ohttp_client.udl */, + F596D2DF2A68922100C8A817 /* OhttpManager.swift */, + ); + name = ASOhttpClient; + path = "../../../components/as-ohttp-client/ios"; + sourceTree = SOURCE_ROOT; + }; + F54D38042A564653005087FB /* Generated */ = { + isa = PBXGroup; + children = ( + F54D38072A564EC9005087FB /* as_ohttp_client.swift */, + F54D38082A564EC9005087FB /* as_ohttp_clientFFI.h */, + ); + path = Generated; + sourceTree = ""; + }; F8AAC1CB298B40B8000BCDEC /* SyncManager */ = { isa = PBXGroup; children = ( @@ -742,6 +772,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F54D38112A5E09F4005087FB /* as_ohttp_client.udl in Sources */, AB9C392E2A0DA61900AF5ADE /* remote_settings.udl in Sources */, 39AD326F2988468B00E42E13 /* FeatureManifestInterface.swift in Sources */, F8C88393298B4BF80006E9E9 /* syncmanager.udl in Sources */, @@ -771,6 +802,7 @@ 1B3BC94627B1D6A500229CF6 /* ResultError.swift in Sources */, 1B3BC98327B1D9B700229CF6 /* FeatureHolder.swift in Sources */, 1B3BC98527B1D9B800229CF6 /* NimbusMessagingHelpers.swift in Sources */, + F596D2E02A68922100C8A817 /* OhttpManager.swift in Sources */, 395EFD6E2966EB6D00D24B97 /* Bundle+.swift in Sources */, 1BF50F1427B1E17B00A9C8A5 /* PersistedFirefoxAccount.swift in Sources */, 1BF50F1027B1E17B00A9C8A5 /* FxAccountStorage.swift in Sources */, @@ -817,6 +849,7 @@ 1B3BC94F27B1D92800229CF6 /* NimbusTests.swift in Sources */, 1BF50F1927B1E19500A9C8A5 /* FxAccountManagerTests.swift in Sources */, 39083AAB29561E2400FDD302 /* OperationTests.swift in Sources */, + F54D38102A5862E4005087FB /* OhttpTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/OhttpTests.swift b/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/OhttpTests.swift new file mode 100644 index 0000000000..04f69624e6 --- /dev/null +++ b/megazords/ios-rust/MozillaTestServices/MozillaTestServicesTests/OhttpTests.swift @@ -0,0 +1,316 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import XCTest + +@testable import MozillaTestServices + +// These tests cover the integration of the underlying Rust libraries into Swift +// URL{Request,Response} data types, as well as the key management and error +// handling logic of OhttpManager class. + +// A testing model of Client, KeyConfigEndpoint, Relay, Gateway, and Target. This +// includes an OHTTP decryption server to decode messages, but does not model TLS, +// etc. +class FakeOhttpNetwork { + let server = OhttpTestServer() + let configURL = URL(string: "https://gateway.example.com/ohttp-configs")! + let relayURL = URL(string: "https://relay.example.com/")! + + // Create an instance of OhttpManager with networking hooks installed to + // send requests to this model instead of the Internet. + func newOhttpManager() -> OhttpManager { + OhttpManager(configUrl: configURL.absoluteString, + relayUrl: relayURL.absoluteString, + network: client) + } + + // Response helpers + func statusResponse(request: URLRequest, statusCode: Int) -> (Data, HTTPURLResponse) { + (Data(), + HTTPURLResponse(url: request.url!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: [:])!) + } + + func dataResponse(request: URLRequest, body: Data, contentType: String) -> (Data, HTTPURLResponse) { + (body, + HTTPURLResponse(url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Length": String(body.count), + "Content-Type": contentType])!) + } + + // + // Network node models + // + func client(_ request: URLRequest) async throws -> (Data, URLResponse) { + switch request.url { + case configURL: return config(request) + case relayURL: return relay(request) + default: throw NSError() + } + } + + func config(_ request: URLRequest) -> (Data, URLResponse) { + let key = server.getConfig() + return dataResponse(request: request, + body: Data(key), + contentType: "application/octet-stream") + } + + func relay(_ request: URLRequest) -> (Data, URLResponse) { + return gateway(request) + } + + func gateway(_ request: URLRequest) -> (Data, URLResponse) { + let inner = try! server.receive(message: [UInt8](request.httpBody!)) + + // Unwrap OHTTP/BHTTP + var innerUrl = URLComponents() + innerUrl.scheme = inner.scheme + innerUrl.host = inner.server + innerUrl.path = inner.endpoint + var innerRequest = URLRequest(url: innerUrl.url!) + innerRequest.httpMethod = inner.method + innerRequest.httpBody = Data(inner.payload) + for (k, v) in inner.headers { + innerRequest.setValue(v, forHTTPHeaderField: k) + } + + let (innerData, innerResponse) = target(innerRequest) + + // Wrap with BHTTP/OHTTP + var headers: [String: String] = [:] + for (k, v) in innerResponse.allHeaderFields { + headers[k as! String] = v as? String + } + let reply = try! server.respond(response: OhttpResponse(statusCode: UInt16(innerResponse.statusCode), + headers: headers, + payload: [UInt8](innerData))) + return dataResponse(request: request, + body: Data(reply), + contentType: "message/ohttp-res") + } + + func target(_ request: URLRequest) -> (Data, HTTPURLResponse) { + // Dummy JSON application response + let data = try! JSONSerialization.data(withJSONObject: ["hello": "world"]) + return dataResponse(request: request, + body: data, + contentType: "application/json") + } +} + +class OhttpTests: XCTestCase { + override func setUp() { + OhttpManager.keyCache.removeAll() + } + + // Test that a GET request can retrieve expected data from Target, including + // passing headers in each direction. + func testGet() async { + class DataTargetNetwork: FakeOhttpNetwork { + override func target(_ request: URLRequest) -> (Data, HTTPURLResponse) { + XCTAssertEqual(request.url, URL(string: "https://example.com/data")!) + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/octet-stream") + + return dataResponse(request: request, + body: Data([0x10, 0x20, 0x30]), + contentType: "application/octet-stream") + } + } + + let mock = DataTargetNetwork() + let ohttp = mock.newOhttpManager() + + let url = URL(string: "https://example.com/data")! + var request = URLRequest(url: url) + request.setValue("application/octet-stream", forHTTPHeaderField: "Accept") + let (data, response) = try! await ohttp.data(for: request) + + XCTAssertEqual(response.statusCode, 200) + XCTAssertEqual([UInt8](data), [0x10, 0x20, 0x30]) + XCTAssertEqual(response.value(forHTTPHeaderField: "Content-Type"), "application/octet-stream") + } + + // Test that POST requests to an API using JSON work as expected. + func testJsonApi() async { + class JsonTargetNetwork: FakeOhttpNetwork { + override func target(_ request: URLRequest) -> (Data, HTTPURLResponse) { + XCTAssertEqual(request.url, URL(string: "https://example.com/api")!) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(String(decoding: request.httpBody!, as: UTF8.self), + #"{"version":1}"#) + + let data = try! JSONSerialization.data(withJSONObject: ["hello": "world"]) + return dataResponse(request: request, + body: data, + contentType: "application/json") + } + } + + let mock = JsonTargetNetwork() + let ohttp = mock.newOhttpManager() + + let url = URL(string: "https://example.com/api")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.httpBody = try! JSONSerialization.data(withJSONObject: ["version": 1]) + let (data, response) = try! await ohttp.data(for: request) + + XCTAssertEqual(response.statusCode, 200) + XCTAssertEqual(String(bytes: data, encoding: .utf8), #"{"hello":"world"}"#) + XCTAssertEqual(response.value(forHTTPHeaderField: "Content-Type"), "application/json") + } + + // Test that config keys are cached across requests. + func testKeyCache() async { + class CountConfigNetwork: FakeOhttpNetwork { + var numConfigFetches = 0 + + override func config(_ request: URLRequest) -> (Data, URLResponse) { + numConfigFetches += 1 + return super.config(request) + } + } + let mock = CountConfigNetwork() + let ohttp = mock.newOhttpManager() + + let request = URLRequest(url: URL(string: "https://example.com/api")!) + _ = try! await ohttp.data(for: request) + _ = try! await ohttp.data(for: request) + _ = try! await ohttp.data(for: request) + + XCTAssertEqual(mock.numConfigFetches, 1) + } + + // Test that bad key config data throws MalformedKeyConfig error. + func testBadConfig() async { + class MalformedKeyNetwork: FakeOhttpNetwork { + override func config(_ request: URLRequest) -> (Data, URLResponse) { + dataResponse(request: request, + body: Data(), + contentType: "application/octet-stream") + } + } + + do { + let mock = MalformedKeyNetwork() + let ohttp = mock.newOhttpManager() + let request = URLRequest(url: URL(string: "https://example.com/api")!) + _ = try await ohttp.data(for: request) + XCTFail() + } catch OhttpError.MalformedKeyConfig { + } catch { + XCTFail() + } + } + + // Test that using the wrong key throws a RelayFailed error and + // that the key is removed from cache. + func testWrongKey() async { + class WrongKeyNetwork: FakeOhttpNetwork { + override func config(_ request: URLRequest) -> (Data, URLResponse) { + dataResponse(request: request, + body: Data(OhttpTestServer().getConfig()), + contentType: "application/octet-stream") + } + + override func gateway(_ request: URLRequest) -> (Data, URLResponse) { + do { + _ = try server.receive(message: [UInt8](request.httpBody!)) + XCTFail() + } catch OhttpError.MalformedMessage { + } catch { + XCTFail() + } + + return statusResponse(request: request, statusCode: 400) + } + } + + do { + let mock = WrongKeyNetwork() + let ohttp = mock.newOhttpManager() + let request = URLRequest(url: URL(string: "https://example.com/")!) + _ = try await ohttp.data(for: request) + XCTFail() + } catch OhttpError.RelayFailed { + } catch { + XCTFail() + } + + XCTAssert(OhttpManager.keyCache.isEmpty) + } + + // Test that bad Gateway data generates MalformedMessage errors. + func testBadGateway() async { + class BadGatewayNetwork: FakeOhttpNetwork { + override func gateway(_ request: URLRequest) -> (Data, URLResponse) { + dataResponse(request: request, + body: Data(), + contentType: "message/ohttp-res") + } + } + + do { + let mock = BadGatewayNetwork() + let ohttp = mock.newOhttpManager() + let request = URLRequest(url: URL(string: "https://example.com/api")!) + _ = try await ohttp.data(for: request) + XCTFail() + } catch OhttpError.MalformedMessage { + } catch { + XCTFail() + } + } + + // Test behaviour when Gateway disallows a Target URL. + func testDisallowedTarget() async { + class DisallowedTargetNetwork: FakeOhttpNetwork { + override func target(_ request: URLRequest) -> (Data, HTTPURLResponse) { + statusResponse(request: request, statusCode: 403) + } + } + + let mock = DisallowedTargetNetwork() + let ohttp = mock.newOhttpManager() + let request = URLRequest(url: URL(string: "https://deny.example.com/")!) + let (_, response) = try! await ohttp.data(for: request) + + XCTAssertEqual(response.statusCode, 403) + } + + // Test that ordinary network failures are surfaced as URLError. + func testNetworkFailure() async { + class NoConnectionNetwork: FakeOhttpNetwork { + override func client(_ request: URLRequest) async throws -> (Data, URLResponse) { + if request.url == configURL { + return config(request) + } + + throw NSError(domain: NSURLErrorDomain, + code: URLError.cannotConnectToHost.rawValue) + } + } + + do { + let mock = NoConnectionNetwork() + let ohttp = mock.newOhttpManager() + let request = URLRequest(url: URL(string: "https://example.com/api")!) + _ = try await ohttp.data(for: request) + XCTFail() + } catch is URLError { + } catch { + XCTFail() + } + } +} diff --git a/megazords/ios-rust/build-xcframework.sh b/megazords/ios-rust/build-xcframework.sh index ae4b901aaa..33bc6552ae 100755 --- a/megazords/ios-rust/build-xcframework.sh +++ b/megazords/ios-rust/build-xcframework.sh @@ -168,6 +168,7 @@ if [ -z $IS_FOCUS ]; then $CARGO uniffi-bindgen generate "$REPO_ROOT/components/places/src/places.udl" -l swift -o "$COMMON/Headers" $CARGO uniffi-bindgen generate "$REPO_ROOT/components/sync_manager/src/syncmanager.udl" -l swift -o "$COMMON/Headers" $CARGO uniffi-bindgen generate "$REPO_ROOT/components/sync15/src/sync15.udl" -l swift -o "$COMMON/Headers" + $CARGO uniffi-bindgen generate "$REPO_ROOT/components/as-ohttp-client/src/as_ohttp_client.udl" -l swift -o "$COMMON/Headers" fi rm -rf "$COMMON"/Headers/*.swift diff --git a/megazords/ios-rust/src/lib.rs b/megazords/ios-rust/src/lib.rs index 1d25e347f2..2dddb6c3f9 100644 --- a/megazords/ios-rust/src/lib.rs +++ b/megazords/ios-rust/src/lib.rs @@ -15,6 +15,7 @@ pub use places; pub use push; // TODO: Drop this dependency once firefox-ios switches to using `rust_log_forwarder` for log // forwarding. +pub use as_ohttp_client; pub use rc_log_ffi; pub use remote_settings; pub use rust_log_forwarder;