Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 107 additions & 18 deletions payjoin-ffi/dart/lib/http.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,56 +9,145 @@ import 'payjoin.dart' show OhttpKeys;
/// Fetches the OHTTP keys from a payjoin directory through an OHTTP relay
/// proxy so the directory never observes the client IP address.
///
/// [ohttpRelayUrl] is the HTTP CONNECT proxy that tunnels the request.
/// [ohttpRelayUrl] is the HTTP(S) CONNECT proxy that tunnels the request.
/// [directoryUrl] is the payjoin directory whose `/.well-known/ohttp-gateway`
/// endpoint is queried. [certificate] is the DER-encoded
/// certificate the directory is expected to present, intended for
/// local test setups that use a self-signed directory certificate; leave
/// unset in production so normal system trust-root validation applies.
/// endpoint is queried. [certificate] is the DER-encoded certificate the
/// directory is expected to present, intended for local test setups that use a
/// self-signed directory certificate; leave unset in production so normal
/// system trust-root validation applies. [relayCertificate] serves the same
/// purpose for an HTTPS relay.
Future<OhttpKeys> fetchOhttpKeys({
required String ohttpRelayUrl,
required String directoryUrl,
Uint8List? certificate,
Uint8List? relayCertificate,
Duration timeout = const Duration(seconds: 10),
}) async {
final relayUri = Uri.parse(ohttpRelayUrl);
_validateUrl(relayUri, 'ohttpRelayUrl', ohttpRelayUrl);
final keysUrl = Uri.parse(directoryUrl).resolve('/.well-known/ohttp-gateway');
_validateUrl(keysUrl, 'directoryUrl', directoryUrl);

final client = HttpClient();
client.findProxy = (_) => 'PROXY ${relayUri.host}:${relayUri.port}';
if (certificate != null && certificate.isNotEmpty) {
client.badCertificateCallback = (cert, _, _) =>
_bytesEqual(cert.der, certificate);
final client = HttpClient()..connectionTimeout = timeout;
client.findProxy = (_) => 'PROXY ${_proxyHost(relayUri)}:${_port(relayUri)}';

final directoryCertificateCallback = _httpCertificateCallback(certificate);
if (directoryCertificateCallback != null) {
client.badCertificateCallback = directoryCertificateCallback;
}

if (relayUri.scheme == 'https') {
// Dart's proxy grammar only accepts DIRECT and PROXY. Advertising an HTTPS
// relay as PROXY still lets HttpClient do CONNECT, while connectionFactory
// opens the underlying TLS connection to the relay.
client.connectionFactory = (_, proxyHost, proxyPort) {
if (proxyHost == null || proxyPort == null) {
throw StateError('fetchOhttpKeys expected a proxy connection');
}
return SecureSocket.startConnect(
proxyHost,
proxyPort,
onBadCertificate: _secureSocketCertificateCallback(relayCertificate),
);
};
}

try {
final request = await client.getUrl(keysUrl);
final request = await client
.getUrl(keysUrl)
.timeout(
timeout,
onTimeout: () => throw HttpException(
'fetchOhttpKeys connection timed out',
uri: keysUrl,
),
);
request.headers.set(HttpHeaders.acceptHeader, 'application/ohttp-keys');
final response = await request.close();
final bodyBytes = await _collectBytes(response);

final response = await request.close().timeout(
timeout,
onTimeout: () =>
throw HttpException('fetchOhttpKeys request timed out', uri: keysUrl),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException(
'fetchOhttpKeys failed: HTTP ${response.statusCode}',
uri: keysUrl,
);
}
final bodyBytes = await _collectBytes(response).timeout(
timeout,
onTimeout: () => throw HttpException(
'fetchOhttpKeys response timed out',
uri: keysUrl,
),
);
return OhttpKeys.decode(bytes: bodyBytes);
} finally {
client.close(force: true);
}
}

bool _bytesEqual(Uint8List a, Uint8List b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
bool Function(X509Certificate, String, int)? _httpCertificateCallback(
Uint8List? certificate,
) {
if (certificate == null || certificate.isEmpty) return null;
return (cert, _, _) => _bytesEqual(cert.der, certificate);
}

bool Function(X509Certificate)? _secureSocketCertificateCallback(
Uint8List? certificate,
) {
if (certificate == null || certificate.isEmpty) return null;
return (cert) => _bytesEqual(cert.der, certificate);
}

final _hostPattern = RegExp(r'^[A-Za-z0-9._\-:]+$');

void _validateUrl(Uri uri, String parameter, String source) {
if (uri.scheme != 'http' && uri.scheme != 'https') {
throw ArgumentError.value(
source,
parameter,
'scheme must be http or https',
);
}
return true;
if (uri.host.isEmpty || !_hostPattern.hasMatch(uri.host)) {
throw ArgumentError.value(source, parameter, 'invalid host');
}
final port = _port(uri);
if (port < 1 || port > 65535) {
throw ArgumentError.value(source, parameter, 'invalid port');
}
}

int _port(Uri uri) {
final port = uri.port;
if (port != 0) return port;
return uri.scheme == 'https' ? 443 : 80;
}

String _proxyHost(Uri uri) {
return uri.host.contains(':') ? '[${uri.host}]' : uri.host;
}

const _maxBodyBytes = 1024;

Future<Uint8List> _collectBytes(Stream<List<int>> stream) async {
final builder = BytesBuilder(copy: false);
await for (final chunk in stream) {
if (builder.length + chunk.length > _maxBodyBytes) {
throw HttpException('response body exceeds $_maxBodyBytes bytes');
}
builder.add(chunk);
}
return builder.toBytes();
}

bool _bytesEqual(Uint8List a, Uint8List b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
231 changes: 231 additions & 0 deletions payjoin-ffi/dart/test/fetch_ohttp_keys_http_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';

import 'package:test/test.dart';

import 'package:payjoin/http.dart' as payjoin_http;
import 'package:payjoin/payjoin.dart' as payjoin;

const _localhostCertPem =
'-----BEGIN CERTIFICATE-----\n'
'MIIBmDCCAT+gAwIBAgIUZmuZcOJ7AKPKxmXUdl8mALQqLjkwCgYIKoZIzj0EAwIw\n'
'FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDQyMzAyNDYzNloXDTM2MDQyMDAy\n'
'NDYzNlowFDESMBAGA1UEAwwJbG9jYWxob3N0MFkwEwYHKoZIzj0CAQYIKoZIzj0D\n'
'AQcDQgAEMbQWJrdEdXIX4hHIcRcpMlRY8+YpH9X9e5DkreID6fVh9tRIoqFSURL1\n'
'L8q2mLIxLl4W5L4HRJuPkKVCiUI9/qNvMG0wHQYDVR0OBBYEFKih01KF98UDEZg6\n'
'FOo4nHUVpKGLMB8GA1UdIwQYMBaAFKih01KF98UDEZg6FOo4nHUVpKGLMA8GA1Ud\n'
'EwEB/wQFMAMBAf8wGgYDVR0RBBMwEYIJbG9jYWxob3N0hwR/AAABMAoGCCqGSM49\n'
'BAMCA0cAMEQCIGCqHp/SwHSxkOxECoU6qEq+/kapotRRTSe3SsWRjQ38AiBITEBf\n'
'j3sL7LkmerQLhWwl7u7UMHb0rBeuFbSmRmxCGA==\n'
'-----END CERTIFICATE-----';

const _localhostKeyPem =
'-----BEGIN PRIVATE KEY-----\n'
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUZxLsCYEttJjv9WN\n'
'6xUtRwFu40pdAk80R8tgCIlqbhahRANCAAQxtBYmt0R1chfiEchxFykyVFjz5ikf\n'
'1f17kOSt4gPp9WH21EiioVJREvUvyraYsjEuXhbkvgdEm4+QpUKJQj3+\n'
'-----END PRIVATE KEY-----';

final _ohttpKeysBytes = Uint8List.fromList(
_hexToBytes(
'01001604ba48c49c3d4a92a3ad00ecc63a024da10ced02180c73ec12d8a7ad'
'2cc91bb483824fe2bee8d28bfe2eb2fc6453bc4d31cd851e8a6540e86c5382'
'af588d370957000400010003',
),
);

List<int> _hexToBytes(String hex) {
final bytes = <int>[];
for (var i = 0; i < hex.length; i += 2) {
bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
}
return bytes;
}

Uint8List _localhostCertDer() => base64.decode(
_localhostCertPem.replaceAll(RegExp(r'-----[^-]+-----|\s'), ''),
);

SecurityContext _localhostSecurityContext() {
return SecurityContext()
..useCertificateChainBytes(utf8.encode(_localhostCertPem))
..usePrivateKeyBytes(utf8.encode(_localhostKeyPem));
}

List<int> _contentLengthResponse() {
return [
...ascii.encode(
'HTTP/1.1 200 OK\r\n'
'Content-Length: ${_ohttpKeysBytes.length}\r\n'
'\r\n',
),
..._ohttpKeysBytes,
];
}

List<int> _chunkedResponseWithExtension() {
return [
...ascii.encode(
'HTTP/1.1 200 OK\r\n'
'Transfer-Encoding: chunked\r\n'
'\r\n'
'${_ohttpKeysBytes.length.toRadixString(16)};proxy=caddy\r\n',
),
..._ohttpKeysBytes,
...ascii.encode('\r\n0\r\n\r\n'),
];
}

int _headerEnd(List<int> bytes) {
for (var i = 0; i <= bytes.length - 4; i++) {
if (bytes[i] == 0x0d &&
bytes[i + 1] == 0x0a &&
bytes[i + 2] == 0x0d &&
bytes[i + 3] == 0x0a) {
return i;
}
}
return -1;
}

Future<SecureServerSocket> _startSecureDirectory(List<int> response) async {
final server = await SecureServerSocket.bind(
'localhost',
0,
_localhostSecurityContext(),
);
server.listen((client) {
final buffer = <int>[];
client.listen((chunk) {
buffer.addAll(chunk);
if (_headerEnd(buffer) == -1) return;
client.add(response);
}, onError: (_) => client.destroy());
});
return server;
}

Future<ServerSocket> _startHttpConnectProxy() async {
final server = await ServerSocket.bind('localhost', 0);
server.listen(_handleConnectProxyClient);
return server;
}

Future<SecureServerSocket> _startHttpsConnectProxy() async {
final server = await SecureServerSocket.bind(
'localhost',
0,
_localhostSecurityContext(),
);
server.listen(_handleConnectProxyClient);
return server;
}

void _handleConnectProxyClient(Socket client) {
final buffer = <int>[];
late StreamSubscription<List<int>> subscription;
subscription = client.listen((chunk) {
buffer.addAll(chunk);
final headerEnd = _headerEnd(buffer);
if (headerEnd == -1) return;
final request = latin1.decode(buffer.sublist(0, headerEnd));
final match = RegExp(
r'^CONNECT ([^:]+):(\d+) HTTP/1\.1',
).firstMatch(request);
if (match == null) {
client.destroy();
return;
}

subscription.pause();
Socket.connect(match.group(1)!, int.parse(match.group(2)!))
.then((target) {
client.add(
ascii.encode('HTTP/1.1 200 Connection Established\r\n\r\n'),
);
subscription
..onData(target.add)
..onError((_) => target.destroy())
..onDone(target.destroy);
target.listen(
client.add,
onDone: client.destroy,
onError: (_) => client.destroy(),
);
subscription.resume();
})
.catchError((_) {
client.destroy();
});
}, onError: (_) => client.destroy());
}

void main() {
group('fetchOhttpKeys HTTP handling', () {
test('uses Content-Length without waiting for connection close', () async {
final directory = await _startSecureDirectory(_contentLengthResponse());
final relay = await _startHttpConnectProxy();
try {
final keys = await payjoin_http.fetchOhttpKeys(
ohttpRelayUrl: 'http://localhost:${relay.port}',
directoryUrl: 'https://localhost:${directory.port}',
certificate: _localhostCertDer(),
timeout: const Duration(seconds: 2),
);
expect(keys, isA<payjoin.OhttpKeys>());
} finally {
await relay.close();
await directory.close();
}
});

test('accepts chunk extensions on chunked responses', () async {
final directory = await _startSecureDirectory(
_chunkedResponseWithExtension(),
);
final relay = await _startHttpConnectProxy();
try {
final keys = await payjoin_http.fetchOhttpKeys(
ohttpRelayUrl: 'http://localhost:${relay.port}',
directoryUrl: 'https://localhost:${directory.port}',
certificate: _localhostCertDer(),
timeout: const Duration(seconds: 2),
);
expect(keys, isA<payjoin.OhttpKeys>());
} finally {
await relay.close();
await directory.close();
}
});

test('supports HTTPS CONNECT relay to HTTPS directory', () async {
final directory = await _startSecureDirectory(_contentLengthResponse());
final relay = await _startHttpsConnectProxy();
try {
final keys = await payjoin_http.fetchOhttpKeys(
ohttpRelayUrl: 'https://localhost:${relay.port}',
directoryUrl: 'https://localhost:${directory.port}',
certificate: _localhostCertDer(),
relayCertificate: _localhostCertDer(),
timeout: const Duration(seconds: 3),
);
expect(keys, isA<payjoin.OhttpKeys>());
} finally {
await relay.close();
await directory.close();
}
});

test('rejects non-http relay schemes', () async {
await expectLater(
payjoin_http.fetchOhttpKeys(
ohttpRelayUrl: 'socks5://127.0.0.1:9000',
directoryUrl: 'https://example.com',
),
throwsArgumentError,
);
});
});
}
Loading