Skip to content

Commit 6dbf70d

Browse files
authored
Added oblivious HTTP support (#46)
* Added oblivious HTTP support * Update README.md Closes #46
1 parent 05e4b93 commit 6dbf70d

File tree

11 files changed

+564
-14
lines changed

11 files changed

+564
-14
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ adheres to [Semantic Versioning][semver].
1414
### Added
1515

1616
- Added `--connect-timeout` command-line argument support. ([#43][#43])
17+
- Added `--ohttp-gateway-url` and `--ohttp-keys-url` command-line arguments. ([#44][#44])
1718

1819
[#43]: https://github.com/ameshkov/gocurl/issues/43
1920

21+
[#44]: https://github.com/ameshkov/gocurl/issues/44
22+
2023
[unreleased]: https://github.com/ameshkov/gocurl/compare/v1.4.9...HEAD
2124

2225
## [1.4.9] - 2025-05-19

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Simplified version of [`curl`](https://curl.se/) written in Go.
1515
- [New stuff](#newstuff)
1616
- [Encrypted ClientHello](#ech)
1717
- [Custom DNS servers](#dns)
18+
- [Oblivious HTTP](#ohttp)
1819
- [Experimental flags](#exp)
1920
- [Post-quantum cryptography](#pq)
2021
- [WebSocket support](#websocket)
@@ -228,6 +229,71 @@ gocurl \
228229
https://example.org/
229230
```
230231
232+
<a id="ohttp"></a>
233+
234+
#### Oblivious HTTP
235+
236+
[Oblivious HTTP (OHTTP)][ohttp] is an IETF standard protocol that provides
237+
end-to-end encryption for HTTP requests and responses while hiding the client's
238+
identity from the target server. It works by routing encrypted requests through
239+
a relay and gateway, ensuring that:
240+
241+
- The **relay** sees who is making requests but not what is being requested.
242+
- The **gateway** sees what is being requested but not who is making the request.
243+
- The **target server** receives a normal HTTP request from the gateway.
244+
245+
This separation provides strong privacy guarantees, making OHTTP useful for
246+
privacy-sensitive applications.
247+
248+
`gocurl` has built-in support for OHTTP and can send requests through any
249+
OHTTP gateway by specifying two command-line arguments:
250+
251+
- `--ohttp-gateway-url` - URL of the OHTTP gateway where the encrypted request
252+
will be sent.
253+
- `--ohttp-keys-url` - URL from which to retrieve the OHTTP KeyConfig needed to
254+
encrypt the request.
255+
256+
Here's how to make an OHTTP request to `httpbin.agrd.workers.dev` using a demo
257+
gateway:
258+
259+
```shell
260+
gocurl -v \
261+
--ohttp-gateway-url "https://httpbin.agrd.workers.dev/ohttp/gateway" \
262+
--ohttp-keys-url "https://httpbin.agrd.workers.dev/ohttp/config" \
263+
https://httpbin.agrd.workers.dev/get
264+
```
265+
266+
This command will:
267+
268+
1. Download the OHTTP KeyConfig from the keys URL.
269+
2. Encrypt your request to `https://httpbin.agrd.workers.dev/get` using OHTTP.
270+
3. Send the encrypted request to the gateway.
271+
4. Receive the encrypted response from the gateway.
272+
5. Decrypt and display the response.
273+
274+
You can also make POST requests through OHTTP:
275+
276+
```shell
277+
gocurl -v \
278+
--ohttp-gateway-url "https://httpbin.agrd.workers.dev/ohttp/gateway" \
279+
--ohttp-keys-url "https://httpbin.agrd.workers.dev/ohttp/config" \
280+
-d "test data" \
281+
https://httpbin.agrd.workers.dev/post
282+
```
283+
284+
One more example that uses a demo gateway from [Oblivious Network][ohttpdemo]:
285+
286+
```shell
287+
gocurl -v \
288+
--ohttp-gateway-url "https://demo-gateway.oblivious.network/gateway" \
289+
--ohttp-keys-url "https://demo-gateway.oblivious.network/ohttp-configs" \
290+
https://httpbin.agrd.workers.dev/get
291+
```
292+
293+
[ohttp]: https://www.ietf.org/rfc/rfc9458.html
294+
295+
[ohttpdemo]: https://docs.oblivious.network/docs/quickstart/
296+
231297
<a id="websocket"></a>
232298
233299
#### WebSocket support
@@ -328,6 +394,10 @@ Application Options:
328394
gocurl will write everything to stdout.
329395
--experiment=<name[:value]> Allows enabling experimental options. See the documentation
330396
for available options. Can be specified multiple times.
397+
--ohttp-gateway-url=<URL> URL of the Oblivious HTTP gateway where the request should
398+
be sent.
399+
--ohttp-keys-url=<URL> URL from which to retrieve Oblivious HTTP KeyConfig to use
400+
for encrypting the request.
331401
-v, --verbose Verbose output (optional).
332402
333403
Help Options:

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/AdguardTeam/dnsproxy v0.67.0
99
github.com/AdguardTeam/golibs v0.22.0
1010
github.com/ameshkov/cfcrypto v0.0.0-20250313151213-d5c09f09fc76
11+
github.com/chris-wood/ohttp-go v0.0.0-20230523152405-45fb0d05eb13
1112
github.com/gobwas/ws v1.3.2
1213
github.com/jessevdk/go-flags v1.5.0
1314
github.com/mccutchen/go-httpbin/v2 v2.18.3
@@ -23,7 +24,7 @@ require (
2324
github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635 // indirect
2425
github.com/ameshkov/dnscrypt/v2 v2.3.0 // indirect
2526
github.com/ameshkov/dnsstamps v1.0.3 // indirect
26-
github.com/cloudflare/circl v1.6.0 // indirect
27+
github.com/cloudflare/circl v1.6.1 // indirect
2728
github.com/davecgh/go-spew v1.1.1 // indirect
2829
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
2930
github.com/gobwas/httphead v0.1.0 // indirect

go.sum

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,12 @@ github.com/ameshkov/dnscrypt/v2 v2.3.0 h1:pDXDF7eFa6Lw+04C0hoMh8kCAQM8NwUdFEllSP
1212
github.com/ameshkov/dnscrypt/v2 v2.3.0/go.mod h1:N5hDwgx2cNb4Ay7AhvOSKst+eUiOZ/vbKRO9qMpQttE=
1313
github.com/ameshkov/dnsstamps v1.0.3 h1:Srzik+J9mivH1alRACTbys2xOxs0lRH9qnTA7Y1OYVo=
1414
github.com/ameshkov/dnsstamps v1.0.3/go.mod h1:Ii3eUu73dx4Vw5O4wjzmT5+lkCwovjzaEZZ4gKyIH5A=
15-
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
16-
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
15+
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
16+
github.com/chris-wood/ohttp-go v0.0.0-20230523152405-45fb0d05eb13 h1:6KPUTuaINL/GlEf3Fd08p/JVVoVRX4Mh4GtsAJUKv7o=
17+
github.com/chris-wood/ohttp-go v0.0.0-20230523152405-45fb0d05eb13/go.mod h1:P/sVWl8F9KHJ1esPj/g1A5h8vfA3Ps9n6JOMNf6TszU=
18+
github.com/cloudflare/circl v1.3.3-0.20230418220640-795540340d5c/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
19+
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
20+
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
1721
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1822
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1923
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -70,6 +74,7 @@ go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
7074
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
7175
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
7276
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
77+
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
7378
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
7479
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
7580
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
@@ -96,6 +101,7 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
96101
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
97102
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
98103
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
104+
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
99105
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
100106
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
101107
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

internal/client/clientdialer.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ type clientDialer struct {
3939
}
4040

4141
// newDialer creates a new instance of the clientDialer.
42-
func newDialer(cfg *config.Config, out *output.Output) (d *clientDialer, err error) {
42+
func newDialer(hostname string, cfg *config.Config, out *output.Output) (d *clientDialer, err error) {
4343
resolver, err := resolve.NewResolver(cfg, out)
4444
if err != nil {
4545
return nil, err
@@ -53,7 +53,7 @@ func newDialer(cfg *config.Config, out *output.Output) (d *clientDialer, err err
5353
return &clientDialer{
5454
cfg: cfg,
5555
out: out,
56-
tlsConfig: createTLSConfig(cfg, out),
56+
tlsConfig: createTLSConfig(hostname, cfg, out),
5757
resolver: resolver,
5858
dial: dial,
5959
}, nil
@@ -202,9 +202,9 @@ func (r *tlsRandomReader) Read(p []byte) (n int, err error) {
202202
}
203203

204204
// createTLSConfig creates TLS config based on the configuration.
205-
func createTLSConfig(cfg *config.Config, out *output.Output) (tlsConfig *tls.Config) {
205+
func createTLSConfig(hostname string, cfg *config.Config, out *output.Output) (tlsConfig *tls.Config) {
206206
tlsConfig = &tls.Config{
207-
ServerName: cfg.RequestURL.Hostname(),
207+
ServerName: hostname,
208208
MinVersion: cfg.TLSMinVersion,
209209
MaxVersion: cfg.TLSMaxVersion,
210210
}

internal/client/ohttp.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package client
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"io"
7+
"net"
8+
"net/http"
9+
"net/url"
10+
11+
"github.com/ameshkov/gocurl/internal/config"
12+
"github.com/ameshkov/gocurl/internal/output"
13+
"github.com/chris-wood/ohttp-go"
14+
)
15+
16+
// obliviousHTTPTransport is transport that uses Oblivious HTTP to encrypt
17+
// requests before sending them to a gateway.
18+
type obliviousHTTPTransport struct {
19+
base Transport
20+
gatewayURL *url.URL
21+
publicConfig ohttp.PublicConfig
22+
out *output.Output
23+
}
24+
25+
// type check
26+
var _ Transport = (*obliviousHTTPTransport)(nil)
27+
28+
// Conn returns the last established connection using this transport.
29+
func (t *obliviousHTTPTransport) Conn() (conn net.Conn) {
30+
return t.base.Conn()
31+
}
32+
33+
// RoundTrip implements the http.RoundTripper interface for
34+
// *obliviousHTTPTransport. It encrypts the request using OHTTP and sends it to
35+
// the gateway.
36+
func (t *obliviousHTTPTransport) RoundTrip(r *http.Request) (resp *http.Response, err error) {
37+
// Create an OHTTP client with the public configuration.
38+
client := ohttp.NewDefaultClient(t.publicConfig)
39+
40+
// Serialize the original request using BinaryRequest format.
41+
binaryReq := (*ohttp.BinaryRequest)(r)
42+
requestBytes, err := binaryReq.Marshal()
43+
if err != nil {
44+
return nil, fmt.Errorf("failed to serialize request: %w", err)
45+
}
46+
47+
t.out.Debug("Encrypting request with OHTTP, original size: %d bytes", len(requestBytes))
48+
49+
// Encrypt the request using OHTTP.
50+
encapsulatedReq, encapContext, err := client.EncapsulateRequest(requestBytes)
51+
if err != nil {
52+
return nil, fmt.Errorf("failed to encapsulate request: %w", err)
53+
}
54+
55+
// Marshal the encapsulated request to bytes.
56+
encapsulatedReqBytes := encapsulatedReq.Marshal()
57+
t.out.Debug("Encrypted request size: %d bytes", len(encapsulatedReqBytes))
58+
59+
// Create a new HTTP POST request to the gateway with the encrypted payload.
60+
gatewayReq, err := http.NewRequest(http.MethodPost, t.gatewayURL.String(), bytes.NewReader(encapsulatedReqBytes))
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to create gateway request: %w", err)
63+
}
64+
65+
// Set the content type for OHTTP requests.
66+
gatewayReq.Header.Set("Content-Type", "message/ohttp-req")
67+
68+
t.out.Debug("Sending encrypted request to gateway: %s", t.gatewayURL.String())
69+
70+
// Send the encrypted request to the gateway.
71+
gatewayResp, err := t.base.RoundTrip(gatewayReq)
72+
if err != nil {
73+
return nil, fmt.Errorf("failed to send request to gateway: %w", err)
74+
}
75+
defer func() {
76+
_ = gatewayResp.Body.Close()
77+
}()
78+
79+
// Verify the gateway response status.
80+
if gatewayResp.StatusCode != http.StatusOK {
81+
return nil, fmt.Errorf("gateway returned non-OK status: %s", gatewayResp.Status)
82+
}
83+
84+
// Verify the gateway response content type.
85+
contentType := gatewayResp.Header.Get("Content-Type")
86+
if contentType != "message/ohttp-res" {
87+
t.out.Debug("Warning: unexpected Content-Type from gateway: %s (expected message/ohttp-res)", contentType)
88+
}
89+
90+
// Read the encrypted response from the gateway.
91+
encapsulatedRespBytes, err := io.ReadAll(gatewayResp.Body)
92+
if err != nil {
93+
return nil, fmt.Errorf("failed to read gateway response: %w", err)
94+
}
95+
96+
t.out.Debug("Received encrypted response from gateway, size: %d bytes", len(encapsulatedRespBytes))
97+
98+
// Unmarshal the encapsulated response.
99+
encapsulatedResp, err := ohttp.UnmarshalEncapsulatedResponse(encapsulatedRespBytes)
100+
if err != nil {
101+
return nil, fmt.Errorf("failed to unmarshal encapsulated response: %w", err)
102+
}
103+
104+
// Decrypt the response using OHTTP.
105+
decryptedResp, err := encapContext.DecapsulateResponse(encapsulatedResp)
106+
if err != nil {
107+
return nil, fmt.Errorf("failed to decapsulate response: %w", err)
108+
}
109+
110+
t.out.Debug("Decrypted response size: %d bytes", len(decryptedResp))
111+
112+
// Parse the decrypted response as an HTTP response using BinaryResponse
113+
// format.
114+
resp, err = ohttp.UnmarshalBinaryResponse(decryptedResp)
115+
if err != nil {
116+
return nil, fmt.Errorf("failed to parse decrypted response: %w", err)
117+
}
118+
119+
return resp, nil
120+
}
121+
122+
func newRoundTripper(
123+
hostname string,
124+
cfg *config.Config,
125+
out *output.Output,
126+
) (rt http.RoundTripper, d *clientDialer, err error) {
127+
d, err = newDialer(hostname, cfg, out)
128+
if err != nil {
129+
return nil, nil, err
130+
}
131+
132+
// Create transport for communicating with the hostname.
133+
rt, err = createHTTPTransport(d, cfg)
134+
if err != nil {
135+
return nil, nil, err
136+
}
137+
138+
return rt, d, nil
139+
}
140+
141+
// newObliviousHTTPTransport creates a new obliviousHTTPTransport.
142+
func newObliviousHTTPTransport(
143+
cfg *config.Config,
144+
out *output.Output,
145+
) (rt Transport, err error) {
146+
// Create base transport for requesting the KeyConfig.
147+
keyTransport, _, err := newRoundTripper(cfg.OHTTPKeysURL.Hostname(), cfg, out)
148+
if err != nil {
149+
return nil, fmt.Errorf("failed to create key transport: %w", err)
150+
}
151+
152+
// Download the KeyConfig from the keys URL.
153+
out.Debug("Downloading OHTTP KeyConfig from: %s", cfg.OHTTPKeysURL.String())
154+
155+
keyReq, err := http.NewRequest(http.MethodGet, cfg.OHTTPKeysURL.String(), nil)
156+
if err != nil {
157+
return nil, fmt.Errorf("failed to create OHTTP KeyConfig request: %w", err)
158+
}
159+
160+
keyResp, err := keyTransport.RoundTrip(keyReq)
161+
if err != nil {
162+
return nil, fmt.Errorf("failed to download OHTTP KeyConfig: %w", err)
163+
}
164+
defer func() {
165+
_ = keyResp.Body.Close()
166+
}()
167+
168+
if keyResp.StatusCode != http.StatusOK {
169+
return nil, fmt.Errorf("failed to download OHTTP KeyConfig, status: %s", keyResp.Status)
170+
}
171+
172+
keyConfigBytes, err := io.ReadAll(keyResp.Body)
173+
if err != nil {
174+
return nil, fmt.Errorf("failed to read OHTTP KeyConfig: %w", err)
175+
}
176+
177+
out.Debug("Downloaded OHTTP KeyConfig, size: %d bytes", len(keyConfigBytes))
178+
179+
// Deserialize and validate the KeyConfig (PublicConfig).
180+
publicConfig, err := ohttp.UnmarshalPublicConfig(keyConfigBytes)
181+
if err != nil {
182+
return nil, fmt.Errorf("failed to deserialize OHTTP KeyConfig: %w", err)
183+
}
184+
185+
out.Debug("OHTTP KeyConfig deserialized successfully, KeyID: %d", publicConfig.ID)
186+
187+
// Create base transport for communicating with the gateway.
188+
gwTransport, d, err := newRoundTripper(cfg.OHTTPGatewayURL.Hostname(), cfg, out)
189+
if err != nil {
190+
return nil, fmt.Errorf("failed to create gateway transport: %w", err)
191+
}
192+
193+
return &obliviousHTTPTransport{
194+
base: &transport{d: d, base: gwTransport},
195+
gatewayURL: cfg.OHTTPGatewayURL,
196+
publicConfig: publicConfig,
197+
out: out,
198+
}, nil
199+
}

internal/client/transport.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,13 @@ func (t *transport) RoundTrip(r *http.Request) (resp *http.Response, err error)
6363
// NewTransport creates a new http.RoundTripper that will be used for making
6464
// the request.
6565
func NewTransport(cfg *config.Config, out *output.Output) (rt Transport, err error) {
66-
d, err := newDialer(cfg, out)
66+
// If OHTTP is enabled, use the oblivious HTTP transport.
67+
if cfg.OHTTPGatewayURL != nil && cfg.OHTTPKeysURL != nil {
68+
return newObliviousHTTPTransport(cfg, out)
69+
}
70+
71+
hostname := cfg.RequestURL.Hostname()
72+
d, err := newDialer(hostname, cfg, out)
6773
if err != nil {
6874
return nil, err
6975
}

0 commit comments

Comments
 (0)