Skip to content

Commit

Permalink
Add Oblivious HTTP (OHTTP) client for iOS
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
moztcampbell committed Jul 28, 2023
1 parent 9049484 commit 53c80d2
Show file tree
Hide file tree
Showing 14 changed files with 1,150 additions and 4 deletions.
288 changes: 284 additions & 4 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions 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",
Expand Down Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions components/as-ohttp-client/Cargo.toml
@@ -0,0 +1,18 @@
[package]
name = "as-ohttp-client"
version = "0.1.0"
edition = "2021"
authors = ["Ted Campbell <tcampbell@mozilla.com>"]
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"]}
7 changes: 7 additions & 0 deletions 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();
}
107 changes: 107 additions & 0 deletions 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)!)
}
}
69 changes: 69 additions & 0 deletions 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<string, string> headers;
sequence<u8> 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<u8> 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<u8> encapsulate([ByRef] string method,
[ByRef] string scheme,
[ByRef] string server,
[ByRef] string endpoint,
record<string, string> headers,
[ByRef] sequence<u8> 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<u8> encoded);
};

dictionary TestServerRequest {
string method;
string scheme;
string server;
string endpoint;
record<string, string> headers;
sequence<u8> 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<u8> get_config();

[Throws=OhttpError]
TestServerRequest receive([ByRef] sequence<u8> message);

[Throws=OhttpError]
sequence<u8> respond(OhttpResponse response);
};

0 comments on commit 53c80d2

Please sign in to comment.