diff --git a/.github/workflows/pr-build-install-selftest.yml b/.github/workflows/pr-build-install-selftest.yml new file mode 100644 index 0000000..5cb14ba --- /dev/null +++ b/.github/workflows/pr-build-install-selftest.yml @@ -0,0 +1,51 @@ +name: PR Build Install Selftest + +on: + pull_request: + workflow_dispatch: + +jobs: + build-install-selftest: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y \ + libwolfssl-dev \ + libnghttp2-dev \ + curl \ + openssl \ + xxd \ + iproute2 + + - name: Build + run: | + make -j"$(nproc)" + + - name: Install to staging prefix + run: | + make install DESTDIR=/tmp/dohd-install-ci VERSION=0.8 + test -x /tmp/dohd-install-ci/usr/local/sbin/dohd + test -x /tmp/dohd-install-ci/usr/local/sbin/dohproxyd + test -x /tmp/dohd-install-ci/usr/local/sbin/ns2dohd + test -x /tmp/dohd-install-ci/usr/local/sbin/odoh-keygen + + - name: Run ODoH proxy+target self-test + run: | + DOHD_BIN=/tmp/dohd-install-ci/usr/local/sbin/dohd \ + DOHPROXYD_BIN=/tmp/dohd-install-ci/usr/local/sbin/dohproxyd \ + WORKDIR=/tmp/dohd-odoh-selftest \ + examples/odoh/selftest-proxy-target-curl.sh + + - name: Upload self-test artifacts on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: odoh-selftest-logs + path: /tmp/dohd-odoh-selftest + if-no-files-found: ignore diff --git a/Makefile b/Makefile index 333e6b2..5aec21d 100644 --- a/Makefile +++ b/Makefile @@ -4,24 +4,32 @@ BINDIR ?= $(PREFIX)/sbin MANDIR ?= $(PREFIX)/share/man build: - make -C src - make -C ns2dohd + $(MAKE) -C src + $(MAKE) -C ns2dohd + $(MAKE) -C proxy + $(MAKE) -C tools debug: - make -C src debug - make -C ns2dohd debug + $(MAKE) -C src debug + $(MAKE) -C ns2dohd debug + $(MAKE) -C proxy debug + $(MAKE) -C tools debug dmalloc: - make -C src dmalloc + $(MAKE) -C src dmalloc asan: - make -C src asan - make -C ns2dohd asan + $(MAKE) -C src asan + $(MAKE) -C ns2dohd asan + $(MAKE) -C proxy asan + $(MAKE) -C tools asan clean: - make -C src clean - make -C ns2dohd clean - make -C test clean + $(MAKE) -C src clean + $(MAKE) -C ns2dohd clean + $(MAKE) -C proxy clean + $(MAKE) -C tools clean + $(MAKE) -C test clean docker-build: docker build -f devops/Dockerfile . -t dyne/dohd:${VERSION} @@ -34,38 +42,38 @@ docker-run: # Run all unit tests check: - make -C test check + $(MAKE) -C test check # Run unit tests with ASAN (for leak detection) check-asan: asan - make -C test check + $(MAKE) -C test check # Run integration tests (requires running dohd instance) check-integration: - make -C test integration + $(MAKE) -C test integration # Run valgrind leak detection test check-valgrind: - make -C test valgrind + $(MAKE) -C test valgrind # Stress tests (auto-launch dohd, bombard until failure) stress: - make -C test stress + $(MAKE) -C test stress stress-escalate: - make -C test stress-escalate + $(MAKE) -C test stress-escalate stress-flood: - make -C test stress-flood + $(MAKE) -C test stress-flood stress-chaos: - make -C test stress-chaos + $(MAKE) -C test stress-chaos stress-all: - make -C test stress-all + $(MAKE) -C test stress-all stress-asan: - make -C test stress-asan + $(MAKE) -C test stress-asan # requires https://github.com/DNS-OARC/flamethrower # default upstream GENERATOR: -g randomlabel lblsize=10 lblcount=4 count=1000 @@ -84,15 +92,33 @@ install: build install -d $(DESTDIR)$(BINDIR) install -m 0755 src/dohd $(DESTDIR)$(BINDIR)/dohd install -m 0755 ns2dohd/ns2dohd $(DESTDIR)$(BINDIR)/ns2dohd + install -m 0755 proxy/dohproxyd $(DESTDIR)$(BINDIR)/dohproxyd + install -m 0755 tools/odoh-keygen $(DESTDIR)$(BINDIR)/odoh-keygen install -d $(DESTDIR)$(MANDIR)/man8 install -m 0644 man/dohd.8 $(DESTDIR)$(MANDIR)/man8/dohd.8 install -m 0644 man/ns2dohd.8 $(DESTDIR)$(MANDIR)/man8/ns2dohd.8 + install -m 0644 man/dohproxyd.8 $(DESTDIR)$(MANDIR)/man8/dohproxyd.8 + install -d $(DESTDIR)$(MANDIR)/man1 + install -m 0644 man/odoh-keygen.1 $(DESTDIR)$(MANDIR)/man1/odoh-keygen.1 + install -d $(DESTDIR)$(PREFIX)/share/dohd/examples + install -m 0755 examples/odoh/deploy-target-example.sh $(DESTDIR)$(PREFIX)/share/dohd/examples/deploy-target-example.sh + install -m 0755 examples/odoh/deploy-proxy-example.sh $(DESTDIR)$(PREFIX)/share/dohd/examples/deploy-proxy-example.sh + install -m 0755 examples/odoh/selftest-proxy-target-curl.sh $(DESTDIR)$(PREFIX)/share/dohd/examples/selftest-proxy-target-curl.sh + install -m 0644 examples/odoh/dodh_targets $(DESTDIR)$(PREFIX)/share/dohd/examples/dodh_targets uninstall: rm -f $(DESTDIR)$(BINDIR)/dohd rm -f $(DESTDIR)$(BINDIR)/ns2dohd + rm -f $(DESTDIR)$(BINDIR)/dohproxyd + rm -f $(DESTDIR)$(BINDIR)/odoh-keygen rm -f $(DESTDIR)$(MANDIR)/man8/dohd.8 rm -f $(DESTDIR)$(MANDIR)/man8/ns2dohd.8 + rm -f $(DESTDIR)$(MANDIR)/man8/dohproxyd.8 + rm -f $(DESTDIR)$(MANDIR)/man1/odoh-keygen.1 + rm -f $(DESTDIR)$(PREFIX)/share/dohd/examples/deploy-target-example.sh + rm -f $(DESTDIR)$(PREFIX)/share/dohd/examples/deploy-proxy-example.sh + rm -f $(DESTDIR)$(PREFIX)/share/dohd/examples/selftest-proxy-target-curl.sh + rm -f $(DESTDIR)$(PREFIX)/share/dohd/examples/dodh_targets .PHONY: build debug dmalloc asan clean docker-build docker-build-alpine docker-run \ check check-asan check-integration check-valgrind check-flame site \ diff --git a/README.md b/README.md index d534e68..be63cde 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,16 @@ option: `--enable-tls13` or simply `--enable-all`). sudo make install ``` +Installed helper tool: + +- `odoh-keygen` (manpage: `man odoh-keygen`) generates ODoH X25519 key material in the exact formats required by `dohd`/`ns2dohd`. + +Example: + +```bash +odoh-keygen -s /etc/dohd/odoh-target.secret -p /etc/dohd/odoh-target.public -c /etc/dohd/odoh-target.config +``` + *** # 🎮 Quick start @@ -119,6 +129,36 @@ Usage: dohd -c cert -k key [-p port] [-d dnsserver] [-F] [-u user] [-V] [-v] [-h - Manpage: `man ns2dohd` - To route system DNS through `ns2dohd`, set `nameserver 127.0.0.1` in `/etc/resolv.conf` or set `127.0.0.1` as primary DNS in NetworkManager. - Run `ns2dohd` as root in daemon mode and drop privileges with `-u`. +- ODoH mode: run `ns2dohd -O --odoh-proxy https://proxy.example/dns-query --odoh-config /path/to/odoh.config ...` + +## dohproxyd + +`dohproxyd` is a standalone DoH/ODoH proxy daemon. + +- Binary: `proxy/dohproxyd` +- Manpage: `man dohproxyd` +- Installed by `make install` together with `dohd` and `ns2dohd` +- Use `--target-cert` and `--target-key` when forwarding to a `dohd -O` target that enforces authorized proxy certificates. +- For legacy RFC8484 forwarding, provide targets with repeated `--target-url` or `--targets-file`; target selection uses RFC-style random rotation. + +## ODoH Deployment Warning (RFC 9230) + +For ODoH privacy properties to hold, **do not deploy proxy and target on the same host or under the same organization**. +The proxy and target are expected to be independently operated and separately observable entities. +If one operator controls or can observe both sides, it can correlate client identity/metadata at the proxy with decrypted DNS content at the target, defeating obliviousness. + +Running both locally is acceptable only for protocol evaluation, development, and interoperability testing. + +## ODoH Helper Scripts + +Example scripts are provided in `examples/odoh/`: + +- `examples/odoh/deploy-target-example.sh` +- `examples/odoh/deploy-proxy-example.sh` +- `examples/odoh/selftest-proxy-target-curl.sh` +- `examples/odoh/dodh_targets` (sample input for `dohproxyd --targets-file`) + +The self-test script intentionally runs proxy+target on one host and uses `curl` for transport checks. It is not a production deployment model. *** # 😍 Acknowledgements diff --git a/examples/odoh/deploy-proxy-example.sh b/examples/odoh/deploy-proxy-example.sh new file mode 100755 index 0000000..88b0720 --- /dev/null +++ b/examples/odoh/deploy-proxy-example.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +# RFC 9230 trust separation warning: +# Deploy this proxy under a different operator and infrastructure than the target. +# Do not co-host proxy and target in production. + +DOHPROXYD_BIN="${DOHPROXYD_BIN:-/usr/local/sbin/dohproxyd}" +RUN_AS="${RUN_AS:-dohd}" +LISTEN_PORT="${LISTEN_PORT:-8443}" + +TLS_CERT="${TLS_CERT:-/etc/dohd/proxy.crt}" +TLS_KEY="${TLS_KEY:-/etc/dohd/proxy.key}" + +# mTLS identity presented by this proxy to targets (if targets require it). +TARGET_CERT="${TARGET_CERT:-/etc/dohd/proxy-client.crt}" +TARGET_KEY="${TARGET_KEY:-/etc/dohd/proxy-client.key}" + +# Optional legacy RFC8484 targets: +# export TARGET_URLS="https://target-a.example/dns-query https://target-b.example/dns-query" +# export TARGETS_FILE="/etc/dohd/dodh_targets" +TARGET_URLS="${TARGET_URLS:-}" +TARGETS_FILE="${TARGETS_FILE:-}" + +set -- \ + "$DOHPROXYD_BIN" \ + -F \ + -u "$RUN_AS" \ + -c "$TLS_CERT" \ + -k "$TLS_KEY" \ + -p "$LISTEN_PORT" \ + --target-cert "$TARGET_CERT" \ + --target-key "$TARGET_KEY" + +if [ -n "$TARGETS_FILE" ]; then + set -- "$@" --targets-file "$TARGETS_FILE" +fi + +if [ -n "$TARGET_URLS" ]; then + for u in $TARGET_URLS; do + set -- "$@" --target-url "$u" + done +fi + +echo "Launching ODoH proxy:" +printf ' %s\n' "$@" +exec "$@" diff --git a/examples/odoh/deploy-target-example.sh b/examples/odoh/deploy-target-example.sh new file mode 100755 index 0000000..dc05208 --- /dev/null +++ b/examples/odoh/deploy-target-example.sh @@ -0,0 +1,41 @@ +#!/bin/sh +set -eu + +# RFC 9230 trust separation warning: +# Run ODoH target and ODoH proxy in different administrative domains. +# This script is only an example of target launch/configuration. + +DOHD_BIN="${DOHD_BIN:-/usr/local/sbin/dohd}" +RUN_AS="${RUN_AS:-dohd}" +LISTEN_PORT="${LISTEN_PORT:-8053}" +UPSTREAM_DNS="${UPSTREAM_DNS:-1.1.1.1}" + +TLS_CERT="${TLS_CERT:-/etc/dohd/target.crt}" +TLS_KEY="${TLS_KEY:-/etc/dohd/target.key}" + +ODOH_CONFIG="${ODOH_CONFIG:-/etc/dohd/odoh-target.config}" +ODOH_SECRET="${ODOH_SECRET:-/etc/dohd/odoh-target.secret}" +AUTHORIZED_PROXY_DIR="${AUTHORIZED_PROXY_DIR:-/etc/dohd/proxies}" + +echo "Launching ODoH target resolver:" +echo " binary: $DOHD_BIN" +echo " tls: $TLS_CERT / $TLS_KEY" +echo " odoh: $ODOH_CONFIG / $ODOH_SECRET" +echo " authz: $AUTHORIZED_PROXY_DIR" +echo +echo "Reminder: install proxy client-cert public keys in:" +echo " $AUTHORIZED_PROXY_DIR" +echo "One PEM file per authorized proxy key." +echo + +exec "$DOHD_BIN" \ + -F \ + -u "$RUN_AS" \ + -c "$TLS_CERT" \ + -k "$TLS_KEY" \ + -p "$LISTEN_PORT" \ + -d "$UPSTREAM_DNS" \ + -O \ + --odoh-config "$ODOH_CONFIG" \ + --odoh-secret "$ODOH_SECRET" \ + --authorized-proxies-dir "$AUTHORIZED_PROXY_DIR" diff --git a/examples/odoh/dodh_targets b/examples/odoh/dodh_targets new file mode 100644 index 0000000..3257aa2 --- /dev/null +++ b/examples/odoh/dodh_targets @@ -0,0 +1,4 @@ +# Example legacy RFC8484 target list for dohproxyd --targets-file +# One https URL per line. +https://target-a.example.net/dns-query +https://target-b.example.net/dns-query diff --git a/examples/odoh/selftest-proxy-target-curl.sh b/examples/odoh/selftest-proxy-target-curl.sh new file mode 100755 index 0000000..8df701a --- /dev/null +++ b/examples/odoh/selftest-proxy-target-curl.sh @@ -0,0 +1,227 @@ +#!/bin/sh +set -eu + +# LOCAL PROTOCOL EVALUATION ONLY +# RFC 9230 recommends separating proxy and target operators. +# This script intentionally runs both on one host for quick protocol plumbing tests. +# Do not use this topology in production. + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)" + +DOHD_BIN="${DOHD_BIN:-$REPO_ROOT/src/dohd}" +DOHPROXYD_BIN="${DOHPROXYD_BIN:-$REPO_ROOT/proxy/dohproxyd}" + +WORKDIR="${WORKDIR:-/tmp/dohd-odoh-selftest}" +TARGET_PORT="${TARGET_PORT:-18053}" +PROXY_PORT="${PROXY_PORT:-18443}" + +TARGET_URL="https://[::1]:${TARGET_PORT}/dns-query" +QUERY_HEX="123401000001000000000000076578616d706c6503636f6d0000010001" + +log() { + printf '[selftest] %s\n' "$*" +} + +dump_logs() { + log "--- target.log ---" + cat "$WORKDIR/target.log" 2>/dev/null || true + log "--- proxy.log ---" + cat "$WORKDIR/proxy.log" 2>/dev/null || true + log "--- curl.headers ---" + cat "$WORKDIR/curl.headers" 2>/dev/null || true + log "--- curl.stderr ---" + cat "$WORKDIR/curl.stderr" 2>/dev/null || true +} + +fail() { + log "FAIL: $*" + dump_logs + exit 1 +} + +cleanup() { + set +e + log "cleanup: stopping child processes" + [ -n "${PROXY_PID:-}" ] && kill "$PROXY_PID" 2>/dev/null + [ -n "${TARGET_PID:-}" ] && kill "$TARGET_PID" 2>/dev/null +} +trap cleanup EXIT INT TERM + +wait_pid_alive() { + p="$1" + name="$2" + n=0 + while [ "$n" -lt 30 ]; do + st="$(ps -o stat= -p "$p" 2>/dev/null | tr -d ' ' || true)" + if [ -z "$st" ]; then + log "$name process $p disappeared" + return 1 + fi + case "$st" in + Z*|*Z*) + log "$name process $p is zombie (state=$st)" + return 1 + ;; + esac + if ! kill -0 "$p" 2>/dev/null; then + log "$name process $p is not alive" + return 1 + fi + n=$((n + 1)) + sleep 0.1 + done + log "$name process $p stayed alive during warmup" + return 0 +} + +wait_tcp_listen() { + port="$1" + name="$2" + n=0 + if ! command -v ss >/dev/null 2>&1; then + log "ss not available, skipping listen-state check for $name" + return 0 + fi + while [ "$n" -lt 50 ]; do + if ss -ltnH "( sport = :$port )" 2>/dev/null | grep -q .; then + log "$name is listening on TCP port $port" + return 0 + fi + n=$((n + 1)) + sleep 0.1 + done + log "$name did not start listening on TCP port $port" + return 1 +} + +mkdir -p "$WORKDIR" +rm -f "$WORKDIR/target.log" "$WORKDIR/proxy.log" "$WORKDIR/curl.headers" "$WORKDIR/curl.stderr" \ + "$WORKDIR/q.bin" "$WORKDIR/r.bin" "$WORKDIR/target.csr" "$WORKDIR/ca.srl" +log "workdir: $WORKDIR" +log "repo root: $REPO_ROOT" +log "target url for proxy: $TARGET_URL" +log "dohd binary: $DOHD_BIN" +log "dohproxyd binary: $DOHPROXYD_BIN" +if command -v ss >/dev/null 2>&1; then + log "pre-check listeners on target/proxy ports (if any):" + ss -ltnH "( sport = :$TARGET_PORT or sport = :$PROXY_PORT )" 2>/dev/null || true +fi + +if ! command -v curl >/dev/null 2>&1; then + fail "curl not found" + exit 1 +fi +if ! command -v openssl >/dev/null 2>&1; then + fail "openssl not found" + exit 1 +fi +if ! command -v xxd >/dev/null 2>&1; then + fail "xxd not found" + exit 1 +fi + +log "generating ephemeral target TLS certificate" +log "generating ephemeral local CA for upstream verification" +openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout "$WORKDIR/ca.key" \ + -out "$WORKDIR/ca.crt" \ + -subj "/CN=dohd-selftest-ca" >/dev/null 2>&1 + +log "generating target key and CSR, then signing with local CA" +openssl req -newkey rsa:2048 -nodes \ + -keyout "$WORKDIR/target.key" \ + -out "$WORKDIR/target.csr" \ + -subj "/CN=localhost" >/dev/null 2>&1 +openssl x509 -req -days 1 \ + -in "$WORKDIR/target.csr" \ + -CA "$WORKDIR/ca.crt" \ + -CAkey "$WORKDIR/ca.key" \ + -CAcreateserial \ + -out "$WORKDIR/target.crt" >/dev/null 2>&1 + +log "generating ephemeral proxy TLS certificate" +openssl req -nodes -newkey rsa:2048 \ + -keyout "$WORKDIR/proxy.key" \ + -x509 -days 1 \ + -out "$WORKDIR/proxy.crt" \ + -subj "/CN=localhost" >/dev/null 2>&1 + +log "building DNS query payload" +printf "%s" "$QUERY_HEX" | xxd -r -p > "$WORKDIR/q.bin" +QSIZE="$(wc -c < "$WORKDIR/q.bin" | tr -d ' ')" +log "query payload size: ${QSIZE} bytes" +log "query payload hexdump:" +xxd -g1 "$WORKDIR/q.bin" || true + +log "starting target dohd on [::]:$TARGET_PORT" +"$DOHD_BIN" \ + -F \ + -v \ + -c "$WORKDIR/target.crt" \ + -k "$WORKDIR/target.key" \ + -p "$TARGET_PORT" \ + -d 1.1.1.1 >"$WORKDIR/target.log" 2>&1 & +TARGET_PID=$! + +"$DOHPROXYD_BIN" \ + -F \ + -v \ + -c "$WORKDIR/proxy.crt" \ + -k "$WORKDIR/proxy.key" \ + -p "$PROXY_PORT" \ + -A "$WORKDIR/ca.crt" \ + --target-url "$TARGET_URL" >"$WORKDIR/proxy.log" 2>&1 & +PROXY_PID=$! +log "proxy pid: $PROXY_PID" + +if ! wait_pid_alive "$TARGET_PID" "target"; then + fail "target exited early" +fi +if ! wait_tcp_listen "$TARGET_PORT" "target"; then + fail "target did not listen" +fi +if ! wait_pid_alive "$PROXY_PID" "proxy"; then + fail "proxy exited early" +fi +if ! wait_tcp_listen "$PROXY_PORT" "proxy"; then + fail "proxy did not listen" +fi + +log "sending DNS over HTTPS request to proxy" +HTTP_CODE="$( +curl --http2 -k -v \ + -D "$WORKDIR/curl.headers" \ + -o "$WORKDIR/r.bin" \ + --write-out "%{http_code}" \ + -H "content-type: application/dns-message" \ + -H "accept: application/dns-message" \ + --data-binary @"$WORKDIR/q.bin" \ + "https://[::1]:${PROXY_PORT}/dns-query" \ + 2>"$WORKDIR/curl.stderr" +)" +log "curl HTTP status: $HTTP_CODE" + +if [ ! -s "$WORKDIR/r.bin" ]; then + fail "empty DNS reply body" +fi + +RSIZE="$(wc -c < "$WORKDIR/r.bin" | tr -d ' ')" +log "reply payload size: ${RSIZE} bytes" +log "reply payload hexdump:" +xxd -g1 "$WORKDIR/r.bin" || true + +RCODE_HEX="$(xxd -p -l 4 "$WORKDIR/r.bin" | cut -c7-8)" +if [ "$RCODE_HEX" = "00" ]; then + log "PASS: proxy+target returned DNS NOERROR" +else + log "completed with DNS flags low-byte=0x$RCODE_HEX" +fi + +log "artifacts:" +log " $WORKDIR/q.bin" +log " $WORKDIR/r.bin" +log " $WORKDIR/target.log" +log " $WORKDIR/proxy.log" +log " $WORKDIR/curl.headers" +log " $WORKDIR/curl.stderr" diff --git a/man/dohd.8 b/man/dohd.8 index 3ffd3b4..af3e77d 100644 --- a/man/dohd.8 +++ b/man/dohd.8 @@ -17,6 +17,17 @@ .B \-u .I user ] [ +.B \-O +] [ +.B \-\-odoh-config +.I file +] [ +.B \-\-odoh-secret +.I file +] [ +.B \-\-authorized-proxies-dir +.I dir +] [ .B \-F ] [ .B \-v @@ -28,6 +39,12 @@ .SH DESCRIPTION .B dohd listens for incoming DoH requests over TLS and forwards DNS payloads to one or more classic DNS resolvers over UDP. +.PP +With +.B \-O +enabled, it acts as an ODoH target resolver and accepts +.B application/oblivious-dns-message +from authorized proxies. .SH OPTIONS .TP .BI \-c " cert" @@ -45,6 +62,26 @@ Upstream DNS resolver IP address. Can be repeated to add multiple resolvers. .BI \-u " user" Drop privileges to this user after binding sockets (when started as root). .TP +.B \-O +Enable ODoH target mode (ODoH media type only). +.TP +.BI \-\-odoh-config " file" +Binary ObliviousDoHConfigs file for target public key configuration. +.TP +.BI \-\-odoh-secret " file" +Private key file for ODoH target (32-byte raw X25519 key). +.IP +Use +.BR odoh-keygen (1) +to create matching +.B \-\-odoh-secret +and +.B \-\-odoh-config +files. +.TP +.BI \-\-authorized-proxies-dir " dir" +Directory containing authorized proxy public keys (PEM), one key per file. +.TP .B \-F Run in foreground (do not daemonize). .TP @@ -69,7 +106,19 @@ Example TLS certificate. .TP .I /etc/test.key Example TLS private key. +.SH SECURITY CONSIDERATIONS +For ODoH privacy properties, do not operate target and proxy on the same host, +network boundary, or organization. RFC 9230 relies on trust separation between +proxy and target operators. +.PP +A single operator that can observe both sides can correlate proxy-side client +metadata with target-side decrypted DNS messages. +.PP +Co-located proxy+target setups are suitable only for protocol evaluation and +interoperability tests. .SH SEE ALSO -.BR ns2dohd (8) +.BR dohproxyd (8), +.BR ns2dohd (8), +.BR odoh-keygen (1) .SH LICENSE GNU AGPLv3. diff --git a/man/dohproxyd.8 b/man/dohproxyd.8 new file mode 100644 index 0000000..fe5f3a1 --- /dev/null +++ b/man/dohproxyd.8 @@ -0,0 +1,106 @@ +.TH DOHPROXYD 8 "February 2026" "dohd 0.8" "System Manager's Manual" +.SH NAME +\fBdohproxyd\fR \- DoH/ODoH proxy daemon +.SH SYNOPSIS +.B dohproxyd +\-c +.I cert +\-k +.I key +[ +.B \-p +.I port +] [ +.B \-u +.I user +] [ +.B \-A +.I cafile +] [ +.B \-\-target-url +.I https://host/path +] [ +.B \-\-targets-file +.I file +] [ +.B \-\-target-cert +.I cert +] [ +.B \-\-target-key +.I key +] [ +.B \-F +] [ +.B \-v +] [ +.B \-V +] [ +.B \-h +] +.SH DESCRIPTION +.B dohproxyd +is a standalone DoH/ODoH proxy. +It accepts +.B application/oblivious-dns-message +requests and forwards encrypted payloads to a target resolver specified in request query parameters. +It also accepts legacy RFC8484 +.B application/dns-message +requests and forwards them to configured targets. +.SH OPTIONS +.TP +.BI \-c " cert" +TLS server certificate chain (PEM). +.TP +.BI \-k " key" +TLS server private key (PEM). +.TP +.BI \-p " port" +Listen port (default: 8443). +.TP +.BI \-u " user" +Drop privileges to user after binding sockets. +.TP +.BI \-A " cafile" +CA bundle used to verify upstream target TLS certificates. +.TP +.BI \-\-target-url " https://host/path" +Add one legacy RFC8484 target URL. Can be repeated. +.TP +.BI \-\-targets-file " file" +Read legacy target URLs from file (one URL per line, '#' comments allowed). +.TP +.BI \-\-target-cert " cert" +Optional client certificate (PEM) presented to ODoH target resolver. +.TP +.BI \-\-target-key " key" +Optional client private key (PEM) for +.BR \-\-target-cert . +.IP +Legacy RFC8484 target selection uses RFC-style random rotation. +.TP +.B \-F +Run in foreground. +.TP +.B \-v +Verbose logging. +.TP +.B \-V +Print version and exit. +.TP +.B \-h +Print help and exit. +.SH SECURITY CONSIDERATIONS +RFC 9230 deployment guidance requires operational separation: +proxy and target should not run on the same host, in the same administrative +domain, or under the same organization if privacy is a goal. +.PP +Otherwise, the same operator can correlate client-side metadata seen by the +proxy with decrypted DNS contents seen by the target. +.PP +Running both on one host is only for protocol testing and debugging. +.SH SEE ALSO +.BR dohd (8), +.BR ns2dohd (8), +.BR odoh-keygen (1) +.SH LICENSE +GNU AGPLv3. diff --git a/man/ns2dohd.8 b/man/ns2dohd.8 index 3b34c48..f8e1c9f 100644 --- a/man/ns2dohd.8 +++ b/man/ns2dohd.8 @@ -15,6 +15,14 @@ .B \-r .I resolver_ip ] [ +.B \-O +] [ +.B \-\-odoh-proxy +.I proxy_url +] [ +.B \-\-odoh-config +.I config_file +] [ .B \-A .I cafile ] [ @@ -50,6 +58,23 @@ Bootstrap DNS resolver IP used for resolving the DoH endpoint host. Default: 1.1 .BI \-A " cafile" CA bundle file in PEM format. .TP +.B \-O +Enable ODoH client mode. +.TP +.BI \-\-odoh-proxy " proxy_url" +ODoH proxy URL used in ODoH mode. +.TP +.BI \-\-odoh-config " config_file" +Binary ObliviousDoHConfigs file for target public key config. +.IP +In ODoH mode, the target resolver from +.B \-d +is automatically encoded as +.B targethost +and +.B targetpath +query parameters for the proxy. +.TP .B \-F Run in foreground (do not daemonize). .TP @@ -61,6 +86,13 @@ Print version and exit. .TP .B \-h Print help and exit. +.SH ODOH DEPLOYMENT NOTE +When using ODoH mode, production privacy requires independent proxy and target +operators as described by RFC 9230. Local or same-organization proxy+target +deployments are for protocol evaluation only. +.PP +Without separation, proxy-observed client metadata can be correlated with +target-decrypted DNS contents. .SH EXAMPLES Run as local DNS daemon and drop privileges to user \fB_dohd\fR: .PP @@ -74,6 +106,8 @@ Use a custom bootstrap resolver: ns2dohd -d https://dns.dyne.org/dns-query -r 9.9.9.9 -u _dohd .fi .SH SEE ALSO -.BR dohd (8) +.BR dohd (8), +.BR dohproxyd (8), +.BR odoh-keygen (1) .SH LICENSE GNU AGPLv3. diff --git a/man/odoh-keygen.1 b/man/odoh-keygen.1 new file mode 100644 index 0000000..42b9367 --- /dev/null +++ b/man/odoh-keygen.1 @@ -0,0 +1,69 @@ +.TH ODOH-KEYGEN 1 "February 2026" "dohd 0.8" "User Commands" +.SH NAME +\fBodoh-keygen\fR \- generate ODoH X25519 key material in dohd-native formats +.SH SYNOPSIS +.B odoh-keygen +[ +.B \-s +.I secret.bin +] [ +.B \-p +.I public.bin +] [ +.B \-c +.I odoh.config +] [ +.B \-h +] +.SH DESCRIPTION +.B odoh-keygen +uses wolfCrypt to generate an X25519 key pair and writes files directly in +the formats expected by +.BR dohd (8) +and +.BR ns2dohd (8): +.IP +\(bu raw 32-byte private key for +.B \-\-odoh-secret +.IP +\(bu raw 32-byte public key +.IP +\(bu binary +.B ObliviousDoHConfigs +blob for +.B \-\-odoh-config +.SH OPTIONS +.TP +.BI \-s " secret.bin" +Output path for raw 32-byte secret key. +Default: +.I odoh-target.secret +.TP +.BI \-p " public.bin" +Output path for raw 32-byte public key. +Default: +.I odoh-target.public +.TP +.BI \-c " odoh.config" +Output path for binary ODoH config blob. +Default: +.I odoh-target.config +.TP +.B \-h +Print help and exit. +.SH EXAMPLE +.nf + odoh-keygen -s /etc/dohd/odoh-target.secret \\ + -p /etc/dohd/odoh-target.public \\ + -c /etc/dohd/odoh-target.config +.fi +.SH SECURITY NOTES +Key generation alone does not provide ODoH privacy. +Per RFC 9230 deployment guidance, run proxy and target under independent +operators and infrastructure. Co-location is for protocol testing only. +.SH SEE ALSO +.BR dohd (8), +.BR dohproxyd (8), +.BR ns2dohd (8) +.SH LICENSE +GNU AGPLv3. diff --git a/ns2dohd/Makefile b/ns2dohd/Makefile index 4fea116..123176c 100644 --- a/ns2dohd/Makefile +++ b/ns2dohd/Makefile @@ -13,7 +13,10 @@ asan: CFLAGS += -fsanitize=address asan: LDFLAGS += -fsanitize=address asan: ns2dohd -ns2dohd: ns2dohd.o +odoh.o: ../src/odoh.c ../src/odoh.h + $(CC) $(CFLAGS) -c -o $@ ../src/odoh.c + +ns2dohd: ns2dohd.o odoh.o $(CC) -o $@ $^ $(LDFLAGS) clean: diff --git a/ns2dohd/README.md b/ns2dohd/README.md index 80ebc1c..a256c7c 100644 --- a/ns2dohd/README.md +++ b/ns2dohd/README.md @@ -56,6 +56,9 @@ ns2dohd -d https://dns.dyne.org/dns-query -u nobody Common options: - `-d ` DoH endpoint URL (mandatory) +- `-O` enable ODoH client mode +- `--odoh-proxy ` ODoH proxy URL (required with `-O`) +- `--odoh-config ` binary target ODoH config file (required with `-O`) - `-p ` local UDP port (default: `53`) - `-u ` drop privileges after bind - `-r ` bootstrap resolver used to resolve the DoH endpoint host (default: `1.1.1.1`) @@ -93,6 +96,14 @@ Notes: - `ns2dohd` uses a separate bootstrap resolver (default `1.1.1.1`) for resolving the DoH endpoint hostname, avoiding resolver recursion. - Change bootstrap resolver with `-r`, for example `-r 9.9.9.9`. +- In ODoH mode, the `-d` endpoint is the target resolver and is automatically passed to the proxy as `targethost` and `targetpath`. + +## ODoH deployment warning (RFC 9230) + +Do not treat a same-host or same-organization proxy+target deployment as private ODoH operation. +The ODoH threat model assumes independent proxy and target operators. + +Local co-location is only suitable for protocol evaluation and debugging. *** # 💼 License diff --git a/ns2dohd/ns2dohd.c b/ns2dohd/ns2dohd.c index c345a71..59a061c 100644 --- a/ns2dohd/ns2dohd.c +++ b/ns2dohd/ns2dohd.c @@ -36,6 +36,8 @@ #include #include +#include "../src/odoh.h" + #define NS2DOHD_PORT 53 #define DNS_BUFFER_MAXSIZE 4096 #define DNS_HEADER_MIN 12 @@ -192,8 +194,11 @@ static void usage(const char *name) { fprintf(stderr, "%s, local DNS to DoH forwarder daemon.\n", name); fprintf(stderr, "License: AGPL\n"); - fprintf(stderr, "Usage: %s -d https://host/path [-p port] [-u user] [-r resolver] [-A cafile] [-F] [-v] [-V] [-h]\n", name); + fprintf(stderr, "Usage: %s -d https://host/path [-O --odoh-proxy url --odoh-config file] [-p port] [-u user] [-r resolver] [-A cafile] [-F] [-v] [-V] [-h]\n", name); fprintf(stderr, "\t'-d': DoH upstream URL (mandatory, https only)\n"); + fprintf(stderr, "\t'-O': Oblivious DoH client mode\n"); + fprintf(stderr, "\t'--odoh-proxy': ODoH proxy URL (required with -O)\n"); + fprintf(stderr, "\t'--odoh-config': ODoH target config file (required with -O)\n"); fprintf(stderr, "\t'-p': local UDP listen port (default: 53)\n"); fprintf(stderr, "\t'-u': user to switch to after binding (root only)\n"); fprintf(stderr, "\t'-r': bootstrap DNS resolver IP (default: 1.1.1.1)\n"); @@ -315,6 +320,66 @@ static int parse_doh_url(const char *url, struct doh_upstream *up) return 0; } +static int url_encode_component(const char *in, char *out, size_t out_sz) +{ + static const char hex[] = "0123456789ABCDEF"; + size_t i; + size_t o = 0; + + if (!in || !out || out_sz == 0) + return -1; + + for (i = 0; in[i] != '\0'; i++) { + unsigned char c = (unsigned char)in[i]; + int unreserved = ((c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~'); + + if (unreserved) { + if ((o + 1) >= out_sz) + return -1; + out[o++] = (char)c; + continue; + } + + if ((o + 3) >= out_sz) + return -1; + out[o++] = '%'; + out[o++] = hex[(c >> 4) & 0x0F]; + out[o++] = hex[c & 0x0F]; + } + + out[o] = '\0'; + return 0; +} + +static int build_odoh_proxy_path(struct doh_upstream *proxy_up, + const struct doh_upstream *target_up) +{ + char enc_host[1024]; + char enc_path[1024]; + char newpath[sizeof(proxy_up->path)]; + const char *sep; + + if (!proxy_up || !target_up) + return -1; + + if (url_encode_component(target_up->authority, enc_host, sizeof(enc_host)) != 0) + return -1; + if (url_encode_component(target_up->path, enc_path, sizeof(enc_path)) != 0) + return -1; + + sep = strchr(proxy_up->path, '?') ? "&" : "?"; + if (snprintf(newpath, sizeof(newpath), "%s%stargethost=%s&targetpath=%s", + proxy_up->path, sep, enc_host, enc_path) >= (int)sizeof(newpath)) { + return -1; + } + + strcpy(proxy_up->path, newpath); + return 0; +} + static int make_servfail(const uint8_t *in, size_t inlen, uint8_t *out, size_t *outlen) { uint16_t flags; @@ -754,6 +819,7 @@ static int h2_on_stream_close_cb(nghttp2_session *session, static int doh_query_roundtrip(WOLFSSL_CTX *wctx, const struct doh_upstream *up, + const char *media_type, const uint8_t *query, size_t query_len, uint8_t *reply, @@ -855,8 +921,8 @@ static int doh_query_roundtrip(WOLFSSL_CTX *wctx, nva[1] = MAKE_NV(":scheme", "https"); nva[2] = MAKE_NV(":authority", up->authority); nva[3] = MAKE_NV(":path", up->path); - nva[4] = MAKE_NV("content-type", "application/dns-message"); - nva[5] = MAKE_NV("accept", "application/dns-message"); + nva[4] = MAKE_NV("content-type", media_type); + nva[5] = MAKE_NV("accept", media_type); nva[6] = MAKE_NV("content-length", clength); data_prd.source.ptr = &x; @@ -931,8 +997,13 @@ static int doh_query_roundtrip(WOLFSSL_CTX *wctx, int main(int argc, char *argv[]) { struct doh_upstream upstream; + struct doh_upstream odoh_proxy_upstream; + odoh_config odoh_cfg; + odoh_client_ctx odoh_client = {}; WOLFSSL_CTX *wctx = NULL; char *url = NULL; + char *odoh_proxy_url = NULL; + char *odoh_config_path = NULL; char *cafile = NULL; char *user = NULL; char *resolver_ip = NULL; @@ -941,6 +1012,7 @@ int main(int argc, char *argv[]) int option_idx = 0; int c; int lfd = -1; + int odoh_mode = 0; struct sockaddr_in addr; uint8_t dns_req[DNS_BUFFER_MAXSIZE]; uint8_t dns_rep[DNS_BUFFER_MAXSIZE]; @@ -952,6 +1024,9 @@ int main(int argc, char *argv[]) {"help", 0, 0, 'h'}, {"version", 0, 0, 'V'}, {"doh-url", 1, 0, 'd'}, + {"oblivion", 0, 0, 'O'}, + {"odoh-proxy", 1, 0, 'P'}, + {"odoh-config", 1, 0, 'C'}, {"port", 1, 0, 'p'}, {"user", 1, 0, 'u'}, {"resolver", 1, 0, 'r'}, @@ -963,7 +1038,7 @@ int main(int argc, char *argv[]) struct sigaction sa = {}; while (1) { - c = getopt_long(argc, argv, "hVd:p:u:r:A:vF", long_options, &option_idx); + c = getopt_long(argc, argv, "hVOd:P:C:p:u:r:A:vF", long_options, &option_idx); if (c < 0) break; @@ -978,6 +1053,17 @@ int main(int argc, char *argv[]) free(url); url = strdup(optarg); break; + case 'O': + odoh_mode = 1; + break; + case 'P': + free(odoh_proxy_url); + odoh_proxy_url = strdup(optarg); + break; + case 'C': + free(odoh_config_path); + odoh_config_path = strdup(optarg); + break; case 'p': if (parse_port(optarg, &port) != 0) { fprintf(stderr, "Invalid port: %s\n", optarg); @@ -1034,6 +1120,49 @@ int main(int argc, char *argv[]) return 2; } + if (odoh_mode) { + if (!odoh_proxy_url || !odoh_config_path) { + fprintf(stderr, "Error: -O requires --odoh-proxy and --odoh-config\n"); + free(user); + free(resolver_ip); + free(cafile); + free(odoh_proxy_url); + free(odoh_config_path); + free(url); + return 2; + } + if (parse_doh_url(odoh_proxy_url, &odoh_proxy_upstream) != 0) { + fprintf(stderr, "Invalid ODoH proxy URL: %s\n", odoh_proxy_url); + free(user); + free(resolver_ip); + free(cafile); + free(odoh_proxy_url); + free(odoh_config_path); + free(url); + return 2; + } + if (odoh_config_load_file(odoh_config_path, &odoh_cfg) != 0) { + fprintf(stderr, "Invalid ODoH config file: %s\n", odoh_config_path); + free(user); + free(resolver_ip); + free(cafile); + free(odoh_proxy_url); + free(odoh_config_path); + free(url); + return 2; + } + if (build_odoh_proxy_path(&odoh_proxy_upstream, &upstream) != 0) { + fprintf(stderr, "Invalid ODoH proxy/target path combination\n"); + free(user); + free(resolver_ip); + free(cafile); + free(odoh_proxy_url); + free(odoh_config_path); + free(url); + return 2; + } + } + if (!resolver_ip) resolver_ip = strdup(DEFAULT_BOOTSTRAP_DNS_IP); if (!resolver_ip || @@ -1055,6 +1184,8 @@ int main(int argc, char *argv[]) free(user); free(resolver_ip); free(cafile); + free(odoh_proxy_url); + free(odoh_config_path); free(url); return 0; } @@ -1066,6 +1197,8 @@ int main(int argc, char *argv[]) free(user); free(resolver_ip); free(cafile); + free(odoh_proxy_url); + free(odoh_config_path); free(url); return 0; } @@ -1195,12 +1328,44 @@ int main(int argc, char *argv[]) continue; } - if (doh_query_roundtrip(wctx, &upstream, dns_req, (size_t)n, dns_rep, &reply_len) != 0) { + if (odoh_mode) { + uint8_t odoh_req[ODOH_MAX_MESSAGE]; + uint8_t odoh_rep[ODOH_MAX_MESSAGE]; + uint16_t odoh_req_len = 0; + uint16_t dns_out_len = 0; + if (odoh_client_encrypt_query(&odoh_cfg, dns_req, (uint16_t)n, + odoh_req, &odoh_req_len, &odoh_client) != 0) { + stats.errors++; + goto odoh_fail; + } + if (doh_query_roundtrip(wctx, &odoh_proxy_upstream, + "application/oblivious-dns-message", + odoh_req, odoh_req_len, + odoh_rep, &reply_len) != 0) { + stats.errors++; + goto odoh_fail; + } + if (odoh_client_decrypt_response(&odoh_client, odoh_rep, (uint16_t)reply_len, + dns_rep, &dns_out_len) != 0) { + stats.errors++; + goto odoh_fail; + } + reply_len = dns_out_len; + goto reply_send; +odoh_fail: + if (make_servfail(dns_req, (size_t)n, dns_rep, &reply_len) == 0) + stats.servfail_replies++; + else + continue; + dohprint(DOH_WARN, "upstream ODoH request failed, returned SERVFAIL"); + } else if (doh_query_roundtrip(wctx, &upstream, + "application/dns-message", dns_req, (size_t)n, dns_rep, &reply_len) != 0) { if (strcmp(upstream.path, "/") == 0) { struct doh_upstream alt = upstream; strcpy(alt.path, "/dns-query"); dohprint(DOH_DEBUG, "retrying DoH query on fallback path"); - if (doh_query_roundtrip(wctx, &alt, dns_req, (size_t)n, dns_rep, &reply_len) == 0) + if (doh_query_roundtrip(wctx, &alt, "application/dns-message", + dns_req, (size_t)n, dns_rep, &reply_len) == 0) goto reply_send; } stats.errors++; @@ -1229,6 +1394,8 @@ int main(int argc, char *argv[]) free(user); free(resolver_ip); free(cafile); + free(odoh_proxy_url); + free(odoh_config_path); if (!foreground) closelog(); diff --git a/ns2dohd/rfc9230.txt b/ns2dohd/rfc9230.txt new file mode 100644 index 0000000..9c4b3ac --- /dev/null +++ b/ns2dohd/rfc9230.txt @@ -0,0 +1,961 @@ + + + + +Independent Submission E. Kinnear +Request for Comments: 9230 Apple Inc. +Category: Experimental P. McManus +ISSN: 2070-1721 Fastly + T. Pauly + Apple Inc. + T. Verma + C.A. Wood + Cloudflare + June 2022 + + + Oblivious DNS over HTTPS + +Abstract + + This document describes a protocol that allows clients to hide their + IP addresses from DNS resolvers via proxying encrypted DNS over HTTPS + (DoH) messages. This improves privacy of DNS operations by not + allowing any one server entity to be aware of both the client IP + address and the content of DNS queries and answers. + + This experimental protocol has been developed outside the IETF and is + published here to guide implementation, ensure interoperability among + implementations, and enable wide-scale experimentation. + +Status of This Memo + + This document is not an Internet Standards Track specification; it is + published for examination, experimental implementation, and + evaluation. + + This document defines an Experimental Protocol for the Internet + community. This is a contribution to the RFC Series, independently + of any other RFC stream. The RFC Editor has chosen to publish this + document at its discretion and makes no statement about its value for + implementation or deployment. Documents approved for publication by + the RFC Editor are not candidates for any level of Internet Standard; + see Section 2 of RFC 7841. + + Information about the current status of this document, any errata, + and how to provide feedback on it may be obtained at + https://www.rfc-editor.org/info/rfc9230. + +Copyright Notice + + Copyright (c) 2022 IETF Trust and the persons identified as the + document authors. All rights reserved. + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents + (https://trustee.ietf.org/license-info) in effect on the date of + publication of this document. Please review these documents + carefully, as they describe your rights and restrictions with respect + to this document. + +Table of Contents + + 1. Introduction + 1.1. Specification of Requirements + 2. Terminology + 3. Deployment Requirements + 4. HTTP Exchange + 4.1. HTTP Request + 4.2. HTTP Request Example + 4.3. HTTP Response + 4.4. HTTP Response Example + 4.5. HTTP Metadata + 5. Configuration and Public Key Format + 6. Protocol Encoding + 6.1. Message Format + 6.2. Encryption and Decryption Routines + 7. Oblivious Client Behavior + 8. Oblivious Target Behavior + 9. Compliance Requirements + 10. Experiment Overview + 11. Security Considerations + 11.1. Denial of Service + 11.2. Proxy Policies + 11.3. Authentication + 12. IANA Considerations + 12.1. Oblivious DoH Message Media Type + 13. References + 13.1. Normative References + 13.2. Informative References + Appendix A. Use of Generic Proxy Services + Acknowledgments + Authors' Addresses + +1. Introduction + + DNS over HTTPS (DoH) [RFC8484] defines a mechanism to allow DNS + messages to be transmitted in HTTP messages protected with TLS. This + provides improved confidentiality and authentication for DNS + interactions in various circumstances. + + While DoH can prevent eavesdroppers from directly reading the + contents of DNS exchanges, clients cannot send DNS queries to and + receive answers from servers without revealing their local IP address + (and thus information about the identity or location of the client) + to the server. + + Proposals such as Oblivious DNS [OBLIVIOUS-DNS] increase privacy by + ensuring that no single DNS server is aware of both the client IP + address and the message contents. + + This document defines Oblivious DoH, an experimental protocol built + on DoH that permits proxied resolution, in which DNS messages are + encrypted so that no server can independently read both the client IP + address and the DNS message contents. + + As with DoH, DNS messages exchanged over Oblivious DoH are fully + formed DNS messages. Clients that want to receive answers that are + relevant to the network they are on without revealing their exact IP + address can thus use the EDNS0 Client Subnet option ([RFC7871], + Section 7.1.2) to provide a hint to the resolver using Oblivious DoH. + + This mechanism is intended to be used as one mechanism for resolving + privacy-sensitive content in the broader context of DNS privacy. + + This experimental protocol has been developed outside the IETF and is + published here to guide implementation, ensure interoperability among + implementations, and enable wide-scale experimentation. See + Section 10 for more details about the experiment. + +1.1. Specification of Requirements + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and + "OPTIONAL" in this document are to be interpreted as described in + BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all + capitals, as shown here. + +2. Terminology + + This document defines the following terms: + + Oblivious Client: A client that sends DNS queries to an Oblivious + Target, through an Oblivious Proxy. The Client is responsible for + selecting the combination of Proxy and Target to use for a given + query. + + Oblivious Proxy: An HTTP server that proxies encrypted DNS queries + and responses between an Oblivious Client and an Oblivious Target + and is identified by a URI Template [RFC6570] (see Section 4.1). + Note that this Oblivious Proxy is not acting as a full HTTP proxy + but is instead a specialized server used to forward Oblivious DNS + messages. + + Oblivious Target: An HTTP server that receives and decrypts + encrypted Oblivious Client DNS queries from an Oblivious Proxy and + returns encrypted DNS responses via that same Proxy. In order to + provide DNS responses, the Target can be a DNS resolver, be co- + located with a resolver, or forward to a resolver. + + Throughout the rest of this document, we use the terms "Client", + "Proxy", and "Target" to refer to an Oblivious Client, Oblivious + Proxy, and Oblivious Target, respectively. + +3. Deployment Requirements + + Oblivious DoH requires, at a minimum: + + * An Oblivious Proxy server, identified by a URI Template. + + * An Oblivious Target server. The Target and Proxy are expected to + be non-colluding (see Section 11). + + * One or more Target public keys for encrypting DNS queries sent to + a Target via a Proxy (Section 5). These keys guarantee that only + the intended Target can decrypt Client queries. + + The mechanism for discovering and provisioning the Proxy URI Template + and Target public keys is out of scope for this document. + +4. HTTP Exchange + + Unlike direct resolution, oblivious hostname resolution over DoH + involves three parties: + + 1. The Client, which generates queries. + + 2. The Proxy, which receives encrypted queries from the Client and + passes them on to a Target. + + 3. The Target, which receives proxied queries from the Client via + the Proxy and produces proxied answers. + + --- [ Request encrypted with Target public key ] --> + +---------+ +-----------+ +-----------+ + | Client +-------------> Oblivious +-------------> Oblivious | + | <-------------+ Proxy <-------------+ Target | + +---------+ +-----------+ +-----------+ + <-- [ Response encrypted with symmetric key ] --- + + Figure 1: Oblivious DoH Exchange + +4.1. HTTP Request + + Oblivious DoH queries are created by the Client and are sent to the + Proxy as HTTP requests using the POST method. Clients are configured + with a Proxy URI Template [RFC6570] and the Target URI. The scheme + for both the Proxy URI Template and the Target URI MUST be "https". + The Proxy URI Template uses the Level 3 encoding defined in + Section 1.2 of [RFC6570] and contains two variables: "targethost", + which indicates the hostname of the Target server; and "targetpath", + which indicates the path on which the Target is accessible. Examples + of Proxy URI Templates are shown below: + + https://dnsproxy.example/dns-query{?targethost,targetpath} + https://dnsproxy.example/{targethost}/{targetpath} + + The URI Template MUST contain both the "targethost" and "targetpath" + variables exactly once and MUST NOT contain any other variables. The + variables MUST be within the path or query components of the URI. + Clients MUST ignore configurations that do not conform to this + template. See Section 4.2 for an example request. + + Oblivious DoH messages have no cache value, since both requests and + responses are encrypted using ephemeral key material. Requests and + responses MUST NOT be cached. + + Clients MUST set the HTTP Content-Type header to "application/ + oblivious-dns-message" to indicate that this request is an Oblivious + DoH query intended for proxying. Clients also SHOULD set this same + value for the HTTP Accept header. + + A correctly encoded request has the HTTP Content-Type header + "application/oblivious-dns-message", uses the HTTP POST method, and + contains "targethost" and "targetpath" variables. If the Proxy fails + to match the "targethost" and "targetpath" variables from the path, + it MUST treat the request as malformed. The Proxy constructs the URI + of the Target with the "https" scheme, using the value of + "targethost" as the URI host and the percent-decoded value of + "targetpath" as the URI path. Proxies MUST check that Client + requests are correctly encoded and MUST return a 4xx (Client Error) + if the check fails, along with the Proxy-Status response header with + an "error" parameter of type "http_request_error" [RFC9209]. + + Proxies MAY choose to not forward connections to non-standard ports. + In such cases, Proxies can indicate the error with a 403 response + status code, along with a Proxy-Status response header with an + "error" parameter of type "http_request_denied" and with an + appropriate explanation in "details". + + If the Proxy cannot establish a connection to the Target, it can + indicate the error with a 502 response status code, along with a + Proxy-Status response header with an "error" parameter whose type + indicates the reason. For example, if DNS resolution fails, the + error type might be "dns_timeout", whereas if the TLS connection + fails, the error type might be "tls_protocol_error". + + Upon receipt of requests from a Proxy, Targets MUST validate that the + request has the HTTP Content-Type header "application/oblivious-dns- + message" and uses the HTTP POST method. Targets can respond with a + 4xx response status code if this check fails. + +4.2. HTTP Request Example + + The following example shows how a Client requests that a Proxy, + "dnsproxy.example", forward an encrypted message to + "dnstarget.example". The URI Template for the Proxy is + "https://dnsproxy.example/dns-query{?targethost,targetpath}". The + URI for the Target is "https://dnstarget.example/dns-query". + + :method = POST + :scheme = https + :authority = dnsproxy.example + :path = /dns-query?targethost=dnstarget.example&targetpath=/dns-query + accept = application/oblivious-dns-message + content-type = application/oblivious-dns-message + content-length = 106 + + + + The Proxy then sends the following request on to the Target: + + :method = POST + :scheme = https + :authority = dnstarget.example + :path = /dns-query + accept = application/oblivious-dns-message + content-type = application/oblivious-dns-message + content-length = 106 + + + +4.3. HTTP Response + + The response to an Oblivious DoH query is generated by the Target. + It MUST set the Content-Type HTTP header to "application/oblivious- + dns-message" for all successful responses. The body of the response + contains an encrypted DNS message; see Section 6. + + The response from a Target MUST set the Content-Type HTTP header to + "application/oblivious-dns-message", and that same type MUST be used + on all successful responses sent by the Proxy to the Client. A + Client MUST only consider a response that contains the Content-Type + header before processing the payload. A response without the + appropriate header MUST be treated as an error and be handled + appropriately. All other aspects of the HTTP response and error + handling are inherited from standard DoH. + + Proxies forward responses from the Target to the Client, without any + modifications to the body or status code. The Proxy also SHOULD add + a Proxy-Status response header with a "received-status" parameter + indicating that the status code was generated by the Target. + + Note that if a Client receives a 3xx status code and chooses to + follow a redirect, the subsequent request MUST also be performed + through a Proxy in order to avoid directly exposing requests to the + Target. + + Requests that cannot be processed by the Target result in 4xx (Client + Error) responses. If the Target and Client keys do not match, it is + an authorization failure (HTTP status code 401; see Section 15.5.2 of + [HTTP]). Otherwise, if the Client's request is invalid, such as in + the case of decryption failure, wrong message type, or + deserialization failure, this is a bad request (HTTP status code 400; + see Section 15.5.1 of [HTTP]). + + Even in the case of DNS responses indicating failure, such as + SERVFAIL or NXDOMAIN, a successful HTTP response with a 2xx status + code is used as long as the DNS response is valid. This is identical + to how DoH [RFC8484] handles HTTP response codes. + +4.4. HTTP Response Example + + The following example shows a 2xx (Successful) response that can be + sent from a Target to a Client via a Proxy. + + :status = 200 + content-type = application/oblivious-dns-message + content-length = 154 + + + +4.5. HTTP Metadata + + Proxies forward requests and responses between Clients and Targets as + specified in Section 4.1. Metadata sent with these messages could + inadvertently weaken or remove Oblivious DoH privacy properties. + Proxies MUST NOT send any Client-identifying information about + Clients to Targets, such as "Forwarded" HTTP headers [RFC7239]. + Additionally, Clients MUST NOT include any private state in requests + to Proxies, such as HTTP cookies. See Section 11.3 for related + discussion about Client authentication information. + +5. Configuration and Public Key Format + + In order to send a message to a Target, the Client needs to know a + public key to use for encrypting its queries. The mechanism for + discovering this configuration is out of scope for this document. + + Servers ought to rotate public keys regularly. It is RECOMMENDED + that servers rotate keys every day. Shorter rotation windows reduce + the anonymity set of Clients that might use the public key, whereas + longer rotation windows widen the time frame of possible compromise. + + An Oblivious DNS public key configuration is a structure encoded, + using TLS-style encoding [RFC8446], as follows: + + struct { + uint16 kem_id; + uint16 kdf_id; + uint16 aead_id; + opaque public_key<1..2^16-1>; + } ObliviousDoHConfigContents; + + struct { + uint16 version; + uint16 length; + select (ObliviousDoHConfig.version) { + case 0x0001: ObliviousDoHConfigContents contents; + } + } ObliviousDoHConfig; + + ObliviousDoHConfig ObliviousDoHConfigs<1..2^16-1>; + + The ObliviousDoHConfigs structure contains one or more + ObliviousDoHConfig structures in decreasing order of preference. + This allows a server to support multiple versions of Oblivious DoH + and multiple sets of Oblivious DoH parameters. + + An ObliviousDoHConfig structure contains a versioned representation + of an Oblivious DoH configuration, with the following fields. + + version: The version of Oblivious DoH for which this configuration + is used. Clients MUST ignore any ObliviousDoHConfig structure + with a version they do not support. The version of Oblivious DoH + specified in this document is 0x0001. + + length: The length, in bytes, of the next field. + + contents: An opaque byte string whose contents depend on the + version. For this specification, the contents are an + ObliviousDoHConfigContents structure. + + An ObliviousDoHConfigContents structure contains the information + needed to encrypt a message under + ObliviousDoHConfigContents.public_key such that only the owner of the + corresponding private key can decrypt the message. The values for + ObliviousDoHConfigContents.kem_id, ObliviousDoHConfigContents.kdf_id, + and ObliviousDoHConfigContents.aead_id are described in Section 7 of + [HPKE]. The fields in this structure are as follows: + + kem_id: The hybrid public key encryption (HPKE) key encapsulation + mechanism (KEM) identifier corresponding to public_key. Clients + MUST ignore any ObliviousDoHConfig structure with a key using a + KEM they do not support. + + kdf_id: The HPKE key derivation function (KDF) identifier + corresponding to public_key. Clients MUST ignore any + ObliviousDoHConfig structure with a key using a KDF they do not + support. + + aead_id: The HPKE authenticated encryption with associated data + (AEAD) identifier corresponding to public_key. Clients MUST + ignore any ObliviousDoHConfig structure with a key using an AEAD + they do not support. + + public_key: The HPKE public key used by the Client to encrypt + Oblivious DoH queries. + +6. Protocol Encoding + + This section includes encoding and wire format details for Oblivious + DoH, as well as routines for encrypting and decrypting encoded + values. + +6.1. Message Format + + There are two types of Oblivious DoH messages: Queries (0x01) and + Responses (0x02). Both messages carry the following information: + + 1. A DNS message, which is either a Query or Response, depending on + context. + + 2. Padding of arbitrary length, which MUST contain all zeros. + + They are encoded using the following structure: + + struct { + opaque dns_message<1..2^16-1>; + opaque padding<0..2^16-1>; + } ObliviousDoHMessagePlaintext; + + Both Query and Response messages use the ObliviousDoHMessagePlaintext + format. + + ObliviousDoHMessagePlaintext ObliviousDoHQuery; + ObliviousDoHMessagePlaintext ObliviousDoHResponse; + + An encrypted ObliviousDoHMessagePlaintext parameter is carried in an + ObliviousDoHMessage message, encoded as follows: + + struct { + uint8 message_type; + opaque key_id<0..2^16-1>; + opaque encrypted_message<1..2^16-1>; + } ObliviousDoHMessage; + + The ObliviousDoHMessage structure contains the following fields: + + message_type: A one-byte identifier for the type of message. Query + messages use message_type 0x01, and Response messages use + message_type 0x02. + + key_id: The identifier of the corresponding + ObliviousDoHConfigContents key. This is computed as + Expand(Extract("", config), "odoh key id", Nh), where config is + the ObliviousDoHConfigContents structure and Extract, Expand, and + Nh are as specified by the HPKE cipher suite KDF corresponding to + config.kdf_id. + + encrypted_message: An encrypted message for the Oblivious Target + (for Query messages) or Client (for Response messages). + Implementations MAY enforce limits on the size of this field, + depending on the size of plaintext DNS messages. (DNS queries, + for example, will not reach the size limit of 2^16-1 in practice.) + + The contents of ObliviousDoHMessage.encrypted_message depend on + ObliviousDoHMessage.message_type. In particular, + ObliviousDoHMessage.encrypted_message is an encryption of an + ObliviousDoHQuery message if the message is a Query and an encryption + of ObliviousDoHResponse if the message is a Response. + +6.2. Encryption and Decryption Routines + + Clients use the following utility functions for encrypting a Query + and decrypting a Response as described in Section 7. + + * encrypt_query_body: Encrypt an Oblivious DoH query. + + def encrypt_query_body(pkR, key_id, Q_plain): + enc, context = SetupBaseS(pkR, "odoh query") + aad = 0x01 || len(key_id) || key_id + ct = context.Seal(aad, Q_plain) + Q_encrypted = enc || ct + return Q_encrypted + + * decrypt_response_body: Decrypt an Oblivious DoH response. + + def decrypt_response_body(context, Q_plain, R_encrypted, resp_nonce): + aead_key, aead_nonce = derive_secrets(context, Q_plain, resp_nonce) + aad = 0x02 || len(resp_nonce) || resp_nonce + R_plain, error = Open(key, nonce, aad, R_encrypted) + return R_plain, error + + The derive_secrets function is described below. + + Targets use the following utility functions in processing queries and + producing responses as described in Section 8. + + * setup_query_context: Set up an HPKE context used for decrypting an + Oblivious DoH query. + + def setup_query_context(skR, key_id, Q_encrypted): + enc || ct = Q_encrypted + context = SetupBaseR(enc, skR, "odoh query") + return context + + * decrypt_query_body: Decrypt an Oblivious DoH query. + + def decrypt_query_body(context, key_id, Q_encrypted): + aad = 0x01 || len(key_id) || key_id + enc || ct = Q_encrypted + Q_plain, error = context.Open(aad, ct) + return Q_plain, error + + * derive_secrets: Derive keying material used for encrypting an + Oblivious DoH response. + + def derive_secrets(context, Q_plain, resp_nonce): + secret = context.Export("odoh response", Nk) + salt = Q_plain || len(resp_nonce) || resp_nonce + prk = Extract(salt, secret) + key = Expand(odoh_prk, "odoh key", Nk) + nonce = Expand(odoh_prk, "odoh nonce", Nn) + return key, nonce + + The random(N) function returns N cryptographically secure random + bytes from a good source of entropy [RFC4086]. The max(A, B) + function returns A if A > B, and B otherwise. + + * encrypt_response_body: Encrypt an Oblivious DoH response. + + def encrypt_response_body(R_plain, aead_key, aead_nonce, resp_nonce): + aad = 0x02 || len(resp_nonce) || resp_nonce + R_encrypted = Seal(aead_key, aead_nonce, aad, R_plain) + return R_encrypted + +7. Oblivious Client Behavior + + Let M be a DNS message (query) a Client wishes to protect with + Oblivious DoH. When sending an Oblivious DoH Query for resolving M + to an Oblivious Target with ObliviousDoHConfigContents config, a + Client does the following: + + 1. Creates an ObliviousDoHQuery structure, carrying the message M + and padding, to produce Q_plain. + + 2. Deserializes config.public_key to produce a public key pkR of + type config.kem_id. + + 3. Computes the encrypted message as Q_encrypted = + encrypt_query_body(pkR, key_id, Q_plain), where key_id is as + computed in Section 6. Note also that len(key_id) outputs the + length of key_id as a two-byte unsigned integer. + + 4. Outputs an ObliviousDoHMessage message Q, where Q.message_type = + 0x01, Q.key_id carries key_id, and Q.encrypted_message = + Q_encrypted. + + The Client then sends Q to the Proxy according to Section 4.1. Once + the Client receives a response R, encrypted as specified in + Section 8, it uses decrypt_response_body to decrypt + R.encrypted_message (using R.key_id as a nonce) and produce R_plain. + Clients MUST validate R_plain.padding (as all zeros) before using + R_plain.dns_message. + +8. Oblivious Target Behavior + + Targets that receive a Query message Q decrypt and process it as + follows: + + 1. Look up the ObliviousDoHConfigContents information according to + Q.key_id. If no such key exists, the Target MAY discard the + query, and if so, it MUST return a 401 (Unauthorized) response to + the Proxy. Otherwise, let skR be the private key corresponding + to this public key, or one chosen for trial decryption. + + 2. Compute context = setup_query_context(skR, Q.key_id, + Q.encrypted_message). + + 3. Compute Q_plain, error = decrypt_query_body(context, Q.key_id, + Q.encrypted_message). + + 4. If no error was returned and Q_plain.padding is valid (all + zeros), resolve Q_plain.dns_message as needed, yielding a DNS + message M. Otherwise, if an error was returned or the padding + was invalid, return a 400 (Client Error) response to the Proxy. + + 5. Create an ObliviousDoHResponseBody structure, carrying the + message M and padding, to produce R_plain. + + 6. Create a fresh nonce resp_nonce = random(max(Nn, Nk)). + + 7. Compute aead_key, aead_nonce = derive_secrets(context, Q_plain, + resp_nonce). + + 8. Compute R_encrypted = encrypt_response_body(R_plain, aead_key, + aead_nonce, resp_nonce). The key_id field used for encryption + carries resp_nonce in order for Clients to derive the same + secrets. Also, the Seal function is the function that is + associated with the HPKE AEAD. + + 9. Output an ObliviousDoHMessage message R, where R.message_type = + 0x02, R.key_id = resp_nonce, and R.encrypted_message = + R_encrypted. + + The Target then sends R in a 2xx (Successful) response to the Proxy; + see Section 4.3. The Proxy forwards the message R without + modification back to the Client as the HTTP response to the Client's + original HTTP request. In the event of an error (non-2xx status + code), the Proxy forwards the Target error to the Client; see + Section 4.3. + +9. Compliance Requirements + + Oblivious DoH uses HPKE for public key encryption [HPKE]. In the + absence of an application profile standard specifying otherwise, a + compliant Oblivious DoH implementation MUST support the following + HPKE cipher suite: + + KEM: DHKEM(X25519, HKDF-SHA256) (see [HPKE], Section 7.1) + + KDF: HKDF-SHA256 (see [HPKE], Section 7.2) + + AEAD: AES-128-GCM (see [HPKE], Section 7.3) + +10. Experiment Overview + + This document describes an experimental protocol built on DoH. The + purpose of this experiment is to assess deployment configuration + viability and related performance impacts on DNS resolution by + measuring key performance indicators such as resolution latency. + Experiment participants will test various parameters affecting + service operation and performance, including mechanisms for discovery + and configuration of DoH Proxies and Targets, as well as performance + implications of connection reuse and pools where appropriate. The + results of this experiment will be used to influence future protocol + design and deployment efforts related to Oblivious DoH, such as + Oblivious HTTP [OHTP]. Implementations of DoH that are not involved + in the experiment will not recognize this protocol and will not + participate in the experiment. It is anticipated that the use of + Oblivious DoH will be widespread and that this experiment will be of + long duration. + +11. Security Considerations + + Oblivious DoH aims to keep knowledge of the true query origin and its + contents known only to Clients. As a simplified model, consider a + case where there exist two Clients C1 and C2, one Proxy P, and one + Target T. Oblivious DoH assumes an extended Dolev-Yao style attacker + [Dolev-Yao] that can observe all network activity and can adaptively + compromise either P or T, but not C1 or C2. Note that compromising + both P and T is equivalent to collusion between these two parties in + practice. Once compromised, the attacker has access to all session + information and private key material. (This generalizes to + arbitrarily many Clients, Proxies, and Targets, with the constraints + that (1) not all Targets and Proxies are simultaneously compromised + and (2) at least two Clients are left uncompromised.) The attacker + is prohibited from sending Client-identifying information, such as IP + addresses, to Targets. (This would allow the attacker to trivially + link a query to the corresponding Client.) + + In this model, both C1 and C2 send Oblivious DoH queries Q1 and Q2, + respectively, through P to T, and T provides answers A1 and A2. The + attacker aims to link C1 to (Q1, A1) and C2 to (Q2, A2), + respectively. The attacker succeeds if this linkability is possible + without any additional interaction. (For example, if T is + compromised, it could return a DNS answer corresponding to an entity + it controls and then observe the subsequent connection from a Client, + learning its identity in the process. Such attacks are out of scope + for this model.) + + Oblivious DoH security prevents such linkability. Informally, this + means: + + 1. Queries and answers are known only to Clients and Targets in + possession of the corresponding response key and HPKE keying + material. In particular, Proxies know the origin and destination + of an oblivious query, yet do not know the plaintext query. + Likewise, Targets know only the oblivious query origin, i.e., the + Proxy, and the plaintext query. Only the Client knows both the + plaintext query contents and destination. + + 2. Target resolvers cannot link queries from the same Client in the + absence of unique per-Client keys. + + Traffic analysis mitigations are outside the scope of this document. + In particular, this document does not prescribe padding lengths for + ObliviousDoHQuery and ObliviousDoHResponse messages. Implementations + SHOULD follow the guidance in [RFC8467] for choosing padding length. + + Oblivious DoH security does not depend on Proxy and Target + indistinguishability. Specifically, an on-path attacker could + determine whether a connection to a specific endpoint is used for + oblivious or direct DoH queries. However, this has no effect on the + confidentiality goals listed above. + +11.1. Denial of Service + + Malicious Clients (or Proxies) can send bogus Oblivious DoH queries + to Targets as a Denial-of-Service (DoS) attack. Target servers can + throttle processing requests if such an event occurs. Additionally, + since Targets provide explicit errors upon decryption failure, i.e., + if ciphertext decryption fails or if the plaintext DNS message is + malformed, Proxies can throttle specific Clients in response to these + errors. In general, however, Targets trust Proxies to not overwhelm + the Target, and it is expected that Proxies implement either some + form of rate limiting or client authentication to limit abuse; see + Section 11.3. + + Malicious Targets or Proxies can send bogus answers in response to + Oblivious DoH queries. Response decryption failure is a signal that + either the Proxy or Target is misbehaving. Clients can choose to + stop using one or both of these servers in the event of such failure. + However, as noted above, malicious Targets and Proxies are out of + scope for the threat model. + +11.2. Proxy Policies + + Proxies are free to enforce any forwarding policy they desire for + Clients. For example, they can choose to only forward requests to + known or otherwise trusted Targets. + + Proxies that do not reuse connections to Targets for many Clients may + allow Targets to link individual queries to unknown Targets. To + mitigate this linkability vector, it is RECOMMENDED that Proxies pool + and reuse connections to Targets. Note that this benefits + performance as well as privacy, since queries do not incur any delay + that might otherwise result from Proxy-to-Target connection + establishment. + +11.3. Authentication + + Depending on the deployment scenario, Proxies and Targets might + require authentication before use. Regardless of the authentication + mechanism in place, Proxies MUST NOT reveal any Client authentication + information to Targets. This is required so Targets cannot uniquely + identify individual Clients. + + Note that if Targets require Proxies to authenticate at the HTTP or + application layer before use, this ought to be done before attempting + to forward any Client query to the Target. This will allow Proxies + to distinguish 401 (Unauthorized) response codes due to + authentication failure from 401 response codes due to Client key + mismatch; see Section 4.3. + +12. IANA Considerations + + This document makes changes to the "Media Types" registry. The + changes are described in the following subsection. + +12.1. Oblivious DoH Message Media Type + + This document registers a new media type, "application/oblivious-dns- + message". + + Type name: application + + Subtype name: oblivious-dns-message + + Required parameters: N/A + + Optional parameters: N/A + + Encoding considerations: This is a binary format, containing + encrypted DNS requests and responses encoded as + ObliviousDoHMessage values, as defined in Section 6.1. + + Security considerations: See this document. The content is an + encrypted DNS message, and not executable code. + + Interoperability considerations: This document specifies the format + of conforming messages and the interpretation thereof; see + Section 6.1. + + Published specification: This document + + Applications that use this media type: This media type is intended + to be used by Clients wishing to hide their DNS queries when using + DNS over HTTPS. + + Additional information: N/A + + Person and email address to contact for further information: See the + Authors' Addresses section. + + Intended usage: COMMON + + Restrictions on usage: N/A + + Author: Tommy Pauly (tpauly@apple.com) + + Change controller: IETF + + Provisional registration? (standards tree only): No + +13. References + +13.1. Normative References + + [HPKE] Barnes, R., Bhargavan, K., Lipp, B., and C. Wood, "Hybrid + Public Key Encryption", RFC 9180, DOI 10.17487/RFC9180, + February 2022, . + + [HTTP] Fielding, R., Ed., Nottingham, M., Ed., and J. Reschke, + Ed., "HTTP Semantics", STD 97, RFC 9110, + DOI 10.17487/RFC9110, June 2022, + . + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, + DOI 10.17487/RFC2119, March 1997, + . + + [RFC4086] Eastlake 3rd, D., Schiller, J., and S. Crocker, + "Randomness Requirements for Security", BCP 106, RFC 4086, + DOI 10.17487/RFC4086, June 2005, + . + + [RFC6570] Gregorio, J., Fielding, R., Hadley, M., Nottingham, M., + and D. Orchard, "URI Template", RFC 6570, + DOI 10.17487/RFC6570, March 2012, + . + + [RFC8174] Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC + 2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174, + May 2017, . + + [RFC8446] Rescorla, E., "The Transport Layer Security (TLS) Protocol + Version 1.3", RFC 8446, DOI 10.17487/RFC8446, August 2018, + . + + [RFC8467] Mayrhofer, A., "Padding Policies for Extension Mechanisms + for DNS (EDNS(0))", RFC 8467, DOI 10.17487/RFC8467, + October 2018, . + + [RFC8484] Hoffman, P. and P. McManus, "DNS Queries over HTTPS + (DoH)", RFC 8484, DOI 10.17487/RFC8484, October 2018, + . + + [RFC9209] Nottingham, M. and P. Sikora, "The Proxy-Status HTTP + Response Header Field", RFC 9209, DOI 10.17487/RFC9209, + June 2022, . + +13.2. Informative References + + [Dolev-Yao] + Dolev, D. and A. C. Yao, "On the Security of Public Key + Protocols", IEEE Transactions on Information Theory, Vol. + IT-29, No. 2, DOI 10.1109/TIT.1983.1056650, March 1983, + . + + [OBLIVIOUS-DNS] + Edmundson, A., Schmitt, P., Feamster, N., and A. Mankin, + "Oblivious DNS - Strong Privacy for DNS Queries", Work in + Progress, Internet-Draft, draft-annee-dprive-oblivious- + dns-00, 2 July 2018, + . + + [OHTP] Thomson, M. and C.A. Wood, "Oblivious HTTP", Work in + Progress, Internet-Draft, draft-ietf-ohai-ohttp-01, 15 + February 2022, . + + [RFC7239] Petersson, A. and M. Nilsson, "Forwarded HTTP Extension", + RFC 7239, DOI 10.17487/RFC7239, June 2014, + . + + [RFC7871] Contavalli, C., van der Gaast, W., Lawrence, D., and W. + Kumari, "Client Subnet in DNS Queries", RFC 7871, + DOI 10.17487/RFC7871, May 2016, + . + +Appendix A. Use of Generic Proxy Services + + Using DoH over anonymizing proxy services such as Tor can also + achieve the desired goal of separating query origins from their + contents. However, there are several reasons why such systems are + undesirable as contrasted with Oblivious DoH: + + 1. Tor is meant to be a generic connection-level anonymity system, + and it incurs higher latency costs and protocol complexity for + the purpose of proxying individual DNS queries. In contrast, + Oblivious DoH is a lightweight protocol built on DoH, implemented + as an application-layer proxy, that can be enabled as a default + mode for users that need increased privacy. + + 2. As a one-hop proxy, Oblivious DoH encourages connectionless + proxies to mitigate Client query correlation with few round + trips. In contrast, multi-hop systems such as Tor often run + secure connections (TLS) end to end, which means that DoH servers + could track queries over the same connection. Using a fresh DoH + connection per query would incur a non-negligible penalty in + connection setup time. + +Acknowledgments + + This work is inspired by Oblivious DNS [OBLIVIOUS-DNS]. Thanks to + all of the authors of that document. Thanks to Nafeez Ahamed, Elliot + Briggs, Marwan Fayed, Jonathan Hoyland, Frederic Jacobs, Tommy + Jensen, Erik Nygren, Paul Schmitt, Brian Swander, and Peter Wu for + their feedback and input. + +Authors' Addresses + + Eric Kinnear + Apple Inc. + One Apple Park Way + Cupertino, California 95014 + United States of America + Email: ekinnear@apple.com + + + Patrick McManus + Fastly + Email: mcmanus@ducksong.com + + + Tommy Pauly + Apple Inc. + One Apple Park Way + Cupertino, California 95014 + United States of America + Email: tpauly@apple.com + + + Tanya Verma + Cloudflare + 101 Townsend St + San Francisco, California 94107 + United States of America + Email: vermatanyax@gmail.com + + + Christopher A. Wood + Cloudflare + 101 Townsend St + San Francisco, California 94107 + United States of America + Email: caw@heapingbits.net diff --git a/proxy/Makefile b/proxy/Makefile new file mode 100644 index 0000000..8f29fb8 --- /dev/null +++ b/proxy/Makefile @@ -0,0 +1,23 @@ +CC=gcc + +CFLAGS := -Wall -Wextra -Wno-sign-compare -DVERSION=\"${VERSION}\" -fPIE +LDFLAGS := -lwolfssl -lrt -lm -lnghttp2 + +all: CFLAGS += -O3 +all: dohproxyd + +debug: CFLAGS += -ggdb -O0 +debug: dohproxyd + +asan: CFLAGS += -fsanitize=address +asan: LDFLAGS += -fsanitize=address +asan: dohproxyd + +libevquick.o: ../src/libevquick.c ../src/libevquick.h ../src/heap.h + $(CC) $(CFLAGS) -c -o $@ ../src/libevquick.c + +dohproxyd: dohproxyd.o libevquick.o + $(CC) -o $@ $^ $(LDFLAGS) + +clean: + rm -f *.o dohproxyd diff --git a/proxy/dohproxyd.c b/proxy/dohproxyd.c new file mode 100644 index 0000000..1e85930 --- /dev/null +++ b/proxy/dohproxyd.c @@ -0,0 +1,1263 @@ +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../src/libevquick.h" + +#define PROXY_PORT 8443 +#define BUF_MAX 65535 +#define TLS_IO_TIMEOUT_SEC 10 +#define DOHPROXY_REQ_MIN 16 + +#define DOH_ERR LOG_EMERG +#define DOH_WARN LOG_WARNING +#define DOH_NOTICE LOG_NOTICE +#define DOH_INFO LOG_INFO +#define DOH_DEBUG LOG_DEBUG + +#define MAKE_NV(K, V) \ + (nghttp2_nv){ \ + (uint8_t *)K, (uint8_t *)V, strlen(K), strlen(V), \ + NGHTTP2_NV_FLAG_NONE \ + } + +struct upstream { + char host[256]; + char authority[320]; + char path[512]; + char port[8]; +}; + +struct forward_ctx { + WOLFSSL *ssl; + int failed; + int status; + int stream_closed; + uint32_t stream_error_code; + int32_t stream_id; + uint8_t *out; + size_t out_cap; + size_t out_len; +}; + +struct req { + struct client *owner; + uint32_t stream_id; + int req_type; + char req_path[1024]; + int path_seen; + char targethost[320]; + char targetpath[512]; + uint8_t body[BUF_MAX]; + uint32_t body_len; + uint32_t body_off; + uint8_t *resp; + uint32_t resp_len; + uint32_t resp_off; + int status_code; +}; + +enum req_type { + REQ_TYPE_NONE = 0, + REQ_TYPE_ODOH = 1, + REQ_TYPE_DOH = 2 +}; + +struct target_conn { + struct upstream up; + int fd; + WOLFSSL *ssl; + nghttp2_session *session; + struct forward_ctx *active_fx; +}; + +struct client { + WOLFSSL *ssl; + int fd; + int tls_done; + nghttp2_session *h2; + struct evquick_event *ev; + struct req req; + struct client *next; +}; + +static int lfd = -1; +static WOLFSSL_CTX *srv_ctx = NULL; +static WOLFSSL_CTX *cli_ctx = NULL; +static struct client *clients = NULL; +static char *target_client_cert = NULL; +static char *target_client_key = NULL; +static struct target_conn *targets = NULL; +static size_t target_count = 0; +static int run = 1; +static int dohprint_loglevel = LOG_NOTICE; +static int dohprint_syslog = -1; + +static void dohprint_init(int fg, int level) +{ + if (fg) + dohprint_syslog = 0; + else + dohprint_syslog = 1; + + if (level > DOH_DEBUG) + level = DOH_DEBUG; + if (level < DOH_ERR) + level = DOH_ERR; + dohprint_loglevel = level; + + if (!fg) + openlog("dohproxyd", LOG_PID, LOG_DAEMON); +} + +static void dohprint(int lvl, const char *fmt, ...) +{ + va_list ap; + + if (dohprint_syslog) { + va_start(ap, fmt); + vsyslog(lvl, fmt, ap); + va_end(ap); + return; + } + + if (lvl <= dohprint_loglevel) { + va_start(ap, fmt); + vfprintf(stderr, fmt, ap); + va_end(ap); + fprintf(stderr, "\n"); + } +} + +static int parse_url(const char *url, struct upstream *up) +{ + const char *prefix = "https://"; + const char *p; + const char *slash; + const char *auth_end; + size_t auth_len; + char auth[320]; + int ipv6_literal = 0; + + memset(up, 0, sizeof(*up)); + + if (!url || strncmp(url, prefix, strlen(prefix)) != 0) + return -1; + + p = url + strlen(prefix); + if (*p == '\0') + return -1; + + slash = strchr(p, '/'); + if (slash) { + size_t path_len = strlen(slash); + if (path_len >= sizeof(up->path)) + return -1; + memcpy(up->path, slash, path_len + 1); + auth_end = slash; + } else { + strcpy(up->path, "/"); + auth_end = p + strlen(p); + } + + auth_len = (size_t)(auth_end - p); + if (auth_len == 0 || auth_len >= sizeof(auth)) + return -1; + memcpy(auth, p, auth_len); + auth[auth_len] = '\0'; + + strcpy(up->port, "443"); + + if (auth[0] == '[') { + char *rb = strchr(auth, ']'); + char *port = NULL; + ipv6_literal = 1; + if (!rb) + return -1; + *rb = '\0'; + if (strlen(auth + 1) >= sizeof(up->host)) + return -1; + strcpy(up->host, auth + 1); + if (*(rb + 1) != '\0') { + if (*(rb + 1) != ':') + return -1; + port = rb + 2; + if (*port == '\0' || strlen(port) >= sizeof(up->port)) + return -1; + strcpy(up->port, port); + } + } else { + char *colon = strrchr(auth, ':'); + if (colon && strchr(auth, ':') == colon) { + *colon = '\0'; + if (*(colon + 1) == '\0' || strlen(colon + 1) >= sizeof(up->port)) + return -1; + strcpy(up->port, colon + 1); + } + if (strlen(auth) == 0 || strlen(auth) >= sizeof(up->host)) + return -1; + strcpy(up->host, auth); + } + + if (strcmp(up->port, "443") == 0) { + if (ipv6_literal) + snprintf(up->authority, sizeof(up->authority), "[%s]", up->host); + else + snprintf(up->authority, sizeof(up->authority), "%s", up->host); + } else { + if (ipv6_literal) + snprintf(up->authority, sizeof(up->authority), "[%s]:%s", up->host, up->port); + else + snprintf(up->authority, sizeof(up->authority), "%s:%s", up->host, up->port); + } + + return 0; +} + +static int add_target_url(const char *url) +{ + struct target_conn *new_targets; + struct target_conn *tc; + + if (!url || *url == '\0') + return -1; + + new_targets = realloc(targets, (target_count + 1) * sizeof(*targets)); + if (!new_targets) + return -1; + + targets = new_targets; + tc = &targets[target_count]; + memset(tc, 0, sizeof(*tc)); + tc->fd = -1; + if (parse_url(url, &tc->up) != 0) + return -1; + target_count++; + return 0; +} + +static int load_targets_file(const char *path) +{ + FILE *fp; + char line[2048]; + + fp = fopen(path, "r"); + if (!fp) + return -1; + + while (fgets(line, sizeof(line), fp)) { + char *p = line; + char *end; + + while (*p && isspace((unsigned char)*p)) + p++; + if (*p == '\0' || *p == '#') + continue; + + end = p + strlen(p); + while (end > p && isspace((unsigned char)end[-1])) + end--; + *end = '\0'; + if (*p == '\0') + continue; + + if (add_target_url(p) != 0) { + fclose(fp); + return -1; + } + } + + fclose(fp); + return 0; +} + +static struct target_conn *select_legacy_target(void) +{ + if (target_count == 0) + return NULL; + { + size_t idx = (size_t)(rand() % (int)target_count); + return &targets[idx]; + } +} + +static int parse_target_from_path(const uint8_t *value, size_t len, + char *targethost, size_t thsz, char *targetpath, size_t tpsz) +{ + const char *q; + const char *th; + const char *tp; + const char *thend; + const char *tpend; + char tmp[1024]; + + if (len >= sizeof(tmp)) + return -1; + memcpy(tmp, value, len); + tmp[len] = '\0'; + + q = strchr(tmp, '?'); + if (!q) + return -1; + + th = strstr(q + 1, "targethost="); + tp = strstr(q + 1, "targetpath="); + if (!th || !tp) + return -1; + + th += strlen("targethost="); + thend = strchr(th, '&'); + if (!thend) + thend = tmp + strlen(tmp); + + tpend = strchr(tp, '&'); + tp += strlen("targetpath="); + if (!tpend) + tpend = tmp + strlen(tmp); + + if (thend <= th || tpend <= tp) + return -1; + + if ((size_t)(thend - th) >= thsz || (size_t)(tpend - tp) >= tpsz) + return -1; + + memcpy(targethost, th, (size_t)(thend - th)); + targethost[thend - th] = '\0'; + memcpy(targetpath, tp, (size_t)(tpend - tp)); + targetpath[tpend - tp] = '\0'; + + { + size_t i = 0, o = 0; + while (targethost[i] != '\0') { + if (targethost[i] == '%' && + targethost[i + 1] != '\0' && + targethost[i + 2] != '\0') { + int hi, lo; + char c1 = targethost[i + 1]; + char c2 = targethost[i + 2]; + hi = (c1 >= '0' && c1 <= '9') ? (c1 - '0') : + (c1 >= 'A' && c1 <= 'F') ? (c1 - 'A' + 10) : + (c1 >= 'a' && c1 <= 'f') ? (c1 - 'a' + 10) : -1; + lo = (c2 >= '0' && c2 <= '9') ? (c2 - '0') : + (c2 >= 'A' && c2 <= 'F') ? (c2 - 'A' + 10) : + (c2 >= 'a' && c2 <= 'f') ? (c2 - 'a' + 10) : -1; + if (hi < 0 || lo < 0) + return -1; + targethost[o++] = (char)((hi << 4) | lo); + i += 3; + continue; + } + targethost[o++] = targethost[i++]; + } + targethost[o] = '\0'; + } + + { + size_t i = 0, o = 0; + while (targetpath[i] != '\0') { + if (targetpath[i] == '%' && + targetpath[i + 1] != '\0' && + targetpath[i + 2] != '\0') { + int hi, lo; + char c1 = targetpath[i + 1]; + char c2 = targetpath[i + 2]; + hi = (c1 >= '0' && c1 <= '9') ? (c1 - '0') : + (c1 >= 'A' && c1 <= 'F') ? (c1 - 'A' + 10) : + (c1 >= 'a' && c1 <= 'f') ? (c1 - 'a' + 10) : -1; + lo = (c2 >= '0' && c2 <= '9') ? (c2 - '0') : + (c2 >= 'A' && c2 <= 'F') ? (c2 - 'A' + 10) : + (c2 >= 'a' && c2 <= 'f') ? (c2 - 'a' + 10) : -1; + if (hi < 0 || lo < 0) + return -1; + targetpath[o++] = (char)((hi << 4) | lo); + i += 3; + continue; + } + targetpath[o++] = targetpath[i++]; + } + targetpath[o] = '\0'; + } + + return 0; +} + +static int tcp_connect(const char *host, const char *port) +{ + struct addrinfo hints, *res = NULL, *rp; + int fd = -1; + struct timeval tv; + + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + if (getaddrinfo(host, port, &hints, &res) != 0) { + dohprint(DOH_WARN, "upstream resolve failed for %s:%s", host, port); + return -1; + } + + tv.tv_sec = TLS_IO_TIMEOUT_SEC; + tv.tv_usec = 0; + + for (rp = res; rp; rp = rp->ai_next) { + fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol); + if (fd < 0) + continue; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) + break; + close(fd); + fd = -1; + } + + freeaddrinfo(res); + if (fd < 0) + dohprint(DOH_WARN, "upstream connect failed for %s:%s", host, port); + return fd; +} + +static ssize_t out_send_cb(nghttp2_session *session, const uint8_t *data, + size_t length, int flags, void *user_data) +{ + struct target_conn *tc = (struct target_conn *)user_data; + int ret; + (void)session; + (void)flags; + + if (!tc || !tc->ssl) + return NGHTTP2_ERR_CALLBACK_FAILURE; + + ret = wolfSSL_write(tc->ssl, data, (int)length); + if (ret > 0) + return ret; + + if (tc->active_fx) + tc->active_fx->failed = 1; + return NGHTTP2_ERR_CALLBACK_FAILURE; +} + +static int out_hdr_cb(nghttp2_session *session, const nghttp2_frame *frame, + const uint8_t *name, size_t namelen, const uint8_t *value, size_t valuelen, + uint8_t flags, void *user_data) +{ + struct target_conn *tc = (struct target_conn *)user_data; + struct forward_ctx *fx = tc ? tc->active_fx : NULL; + char scode[4]; + size_t cp; + (void)session; + (void)flags; + + if (!fx) + return 0; + if (frame->hd.type != NGHTTP2_HEADERS) + return 0; + if (frame->hd.stream_id != fx->stream_id) + return 0; + + if (namelen == 7 && memcmp(name, ":status", 7) == 0) { + cp = valuelen >= sizeof(scode) ? sizeof(scode) - 1 : valuelen; + memcpy(scode, value, cp); + scode[cp] = '\0'; + fx->status = atoi(scode); + dohprint(DOH_DEBUG, "upstream stream %d status=%d", fx->stream_id, fx->status); + } + return 0; +} + +static int out_data_cb(nghttp2_session *session, uint8_t flags, + int32_t stream_id, const uint8_t *data, size_t len, void *user_data) +{ + struct target_conn *tc = (struct target_conn *)user_data; + struct forward_ctx *fx = tc ? tc->active_fx : NULL; + (void)session; + (void)flags; + + if (!fx || stream_id != fx->stream_id) + return 0; + + if (fx->out_len + len > fx->out_cap) { + fx->failed = 1; + return NGHTTP2_ERR_CALLBACK_FAILURE; + } + + memcpy(fx->out + fx->out_len, data, len); + fx->out_len += len; + return 0; +} + +static int out_close_cb(nghttp2_session *session, int32_t stream_id, + uint32_t error_code, void *user_data) +{ + struct target_conn *tc = (struct target_conn *)user_data; + struct forward_ctx *fx = tc ? tc->active_fx : NULL; + (void)session; + (void)error_code; + + if (fx && stream_id == fx->stream_id) + { + fx->stream_error_code = error_code; + fx->stream_closed = 1; + if (error_code != NGHTTP2_NO_ERROR) { + fx->failed = 1; + dohprint(DOH_WARN, "upstream stream %d closed with error_code=%u", + stream_id, error_code); + } + } + return 0; +} + +static ssize_t out_body_read_cb(nghttp2_session *session, int32_t stream_id, + uint8_t *buf, size_t length, uint32_t *data_flags, + nghttp2_data_source *source, void *user_data) +{ + struct req *req = (struct req *)source->ptr; + size_t left; + (void)session; + (void)stream_id; + (void)user_data; + + left = req->body_len - req->body_off; + if (left == 0) { + req->body_off = 0; + *data_flags = NGHTTP2_DATA_FLAG_EOF; + return 0; + } + + if (left > length) + left = length; + + memcpy(buf, req->body + req->body_off, left); + req->body_off += left; + if (req->body_off == req->body_len) + *data_flags = NGHTTP2_DATA_FLAG_EOF; + return (ssize_t)left; +} + +static void close_target_connection(struct target_conn *tc) +{ + if (!tc) + return; + if (tc->session) { + nghttp2_session_del(tc->session); + tc->session = NULL; + } + if (tc->ssl) { + wolfSSL_free(tc->ssl); + tc->ssl = NULL; + } + if (tc->fd >= 0) { + close(tc->fd); + tc->fd = -1; + } + tc->active_fx = NULL; +} + +static int connect_target_connection(struct target_conn *tc) +{ + nghttp2_session_callbacks *cbs = NULL; + nghttp2_settings_entry iv[1] = { + { NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS, 100 } + }; + + if (!tc) + return -1; + if (tc->session && tc->ssl && tc->fd >= 0) + return 0; + + close_target_connection(tc); + tc->fd = tcp_connect(tc->up.host, tc->up.port); + if (tc->fd < 0) + return -1; + + tc->ssl = wolfSSL_new(cli_ctx); + if (!tc->ssl) { + dohprint(DOH_WARN, "wolfSSL_new failed for upstream %s", tc->up.authority); + goto fail; + } + + wolfSSL_set_fd(tc->ssl, tc->fd); + wolfSSL_UseSNI(tc->ssl, WOLFSSL_SNI_HOST_NAME, tc->up.host, + (unsigned short)strlen(tc->up.host)); + wolfSSL_UseALPN(tc->ssl, "h2", 2, WOLFSSL_ALPN_FAILED_ON_MISMATCH); + + if (target_client_cert && target_client_key) { + if (wolfSSL_use_certificate_file(tc->ssl, target_client_cert, SSL_FILETYPE_PEM) != SSL_SUCCESS) { + dohprint(DOH_WARN, "cannot load upstream client certificate %s", target_client_cert); + goto fail; + } + if (wolfSSL_use_PrivateKey_file(tc->ssl, target_client_key, SSL_FILETYPE_PEM) != SSL_SUCCESS) { + dohprint(DOH_WARN, "cannot load upstream client key %s", target_client_key); + goto fail; + } + } + + if (wolfSSL_connect(tc->ssl) != SSL_SUCCESS) { + int err = wolfSSL_get_error(tc->ssl, -1); + dohprint(DOH_WARN, "upstream TLS connect failed to %s (err=%d)", tc->up.authority, err); + goto fail; + } + + if (nghttp2_session_callbacks_new(&cbs) != 0) { + dohprint(DOH_WARN, "nghttp2_session_callbacks_new failed"); + goto fail; + } + nghttp2_session_callbacks_set_send_callback(cbs, out_send_cb); + nghttp2_session_callbacks_set_on_header_callback(cbs, out_hdr_cb); + nghttp2_session_callbacks_set_on_data_chunk_recv_callback(cbs, out_data_cb); + nghttp2_session_callbacks_set_on_stream_close_callback(cbs, out_close_cb); + + if (nghttp2_session_client_new(&tc->session, cbs, tc) != 0) { + dohprint(DOH_WARN, "nghttp2_session_client_new failed for %s", tc->up.authority); + goto fail; + } + nghttp2_session_callbacks_del(cbs); + if (nghttp2_submit_settings(tc->session, NGHTTP2_FLAG_NONE, iv, 1) != 0) { + dohprint(DOH_WARN, "nghttp2_submit_settings failed for %s", tc->up.authority); + goto fail; + } + dohprint(DOH_DEBUG, "connected upstream %s%s", tc->up.authority, tc->up.path); + return 0; + +fail: + if (cbs) + nghttp2_session_callbacks_del(cbs); + close_target_connection(tc); + return -1; +} + +static int forward_to_upstream(struct target_conn *tc, struct req *req, + const char *content_type, uint8_t *out, uint32_t *out_len) +{ + uint8_t tlsbuf[4096]; + nghttp2_data_provider dp; + nghttp2_nv nva[7]; + struct forward_ctx fx = {}; + char clen[32]; + int ret; + int tries; + + for (tries = 0; tries < 2; tries++) { + if (connect_target_connection(tc) != 0) + continue; + + memset(&fx, 0, sizeof(fx)); + fx.ssl = tc->ssl; + fx.out = out; + fx.out_cap = *out_len; + tc->active_fx = &fx; + + req->body_off = 0; + snprintf(clen, sizeof(clen), "%u", req->body_len); + nva[0] = MAKE_NV(":method", "POST"); + nva[1] = MAKE_NV(":scheme", "https"); + nva[2] = MAKE_NV(":authority", tc->up.authority); + nva[3] = MAKE_NV(":path", tc->up.path); + nva[4] = MAKE_NV("content-type", content_type); + nva[5] = MAKE_NV("accept", content_type); + nva[6] = MAKE_NV("content-length", clen); + + memset(&dp, 0, sizeof(dp)); + dp.source.ptr = req; + dp.read_callback = out_body_read_cb; + + fx.stream_id = nghttp2_submit_request(tc->session, NULL, nva, 7, &dp, NULL); + if (fx.stream_id < 0) { + dohprint(DOH_WARN, "nghttp2_submit_request failed for upstream %s", tc->up.authority); + tc->active_fx = NULL; + close_target_connection(tc); + continue; + } + + while (!fx.stream_closed && !fx.failed) { + if (nghttp2_session_send(tc->session) != 0) { + dohprint(DOH_WARN, "nghttp2_session_send failed for upstream %s", tc->up.authority); + fx.failed = 1; + break; + } + + ret = wolfSSL_read(tc->ssl, tlsbuf, sizeof(tlsbuf)); + if (ret <= 0) { + int werr = wolfSSL_get_error(tc->ssl, ret); + if (werr == WOLFSSL_ERROR_WANT_READ || werr == WOLFSSL_ERROR_WANT_WRITE) + continue; + dohprint(DOH_WARN, "upstream TLS read failed for %s (err=%d)", tc->up.authority, werr); + fx.failed = 1; + break; + } + + if (nghttp2_session_mem_recv(tc->session, tlsbuf, (size_t)ret) < 0) { + dohprint(DOH_WARN, "nghttp2_session_mem_recv failed for upstream %s", tc->up.authority); + fx.failed = 1; + break; + } + } + + tc->active_fx = NULL; + if (!fx.failed && fx.status == 200) { + *out_len = (uint32_t)fx.out_len; + dohprint(DOH_DEBUG, "upstream %s replied 200 (%u bytes)", tc->up.authority, *out_len); + return 0; + } + dohprint(DOH_WARN, "upstream %s returned failure (status=%d, failed=%d, stream_err=%u, tries=%d)", + tc->up.authority, fx.status, fx.failed, fx.stream_error_code, tries + 1); + + close_target_connection(tc); + } + return -1; +} + +static int forward_to_dynamic_target(struct req *req, uint8_t *out, uint32_t *out_len) +{ + char full[1024]; + struct target_conn tc = {}; + + if (snprintf(full, sizeof(full), "https://%s%s", req->targethost, req->targetpath) >= (int)sizeof(full)) + return -1; + if (parse_url(full, &tc.up) != 0) + return -1; + tc.fd = -1; + + if (forward_to_upstream(&tc, req, "application/oblivious-dns-message", out, out_len) != 0) { + close_target_connection(&tc); + return -1; + } + + close_target_connection(&tc); + return 0; +} + +static void free_client(struct client *cl) +{ + struct client **pp = &clients; + while (*pp) { + if (*pp == cl) { + *pp = cl->next; + break; + } + pp = &(*pp)->next; + } + + if (cl->ev) + evquick_delevent(cl->ev); + if (cl->h2) + nghttp2_session_del(cl->h2); + if (cl->ssl) + wolfSSL_free(cl->ssl); + if (cl->fd >= 0) + close(cl->fd); + free(cl->req.resp); + free(cl); +} + +static ssize_t in_send_cb(nghttp2_session *session, const uint8_t *data, + size_t length, int flags, void *user_data) +{ + struct client *cl = (struct client *)user_data; + (void)session; + (void)flags; + return wolfSSL_write(cl->ssl, data, (int)length); +} + +static ssize_t in_resp_read_cb(nghttp2_session *session, int32_t stream_id, + uint8_t *buf, size_t length, uint32_t *data_flags, + nghttp2_data_source *source, void *user_data) +{ + struct req *req = (struct req *)source->ptr; + uint32_t left; + (void)session; + (void)stream_id; + (void)user_data; + + left = req->resp_len - req->resp_off; + if (left == 0) { + req->resp_off = 0; + *data_flags = NGHTTP2_DATA_FLAG_EOF; + return 0; + } + + if (left > length) + left = (uint32_t)length; + + memcpy(buf, req->resp + req->resp_off, left); + req->resp_off += left; + if (req->resp_off == req->resp_len) + *data_flags = NGHTTP2_DATA_FLAG_EOF; + return left; +} + +static int in_header_cb(nghttp2_session *session, + const nghttp2_frame *frame, const uint8_t *name, size_t namelen, + const uint8_t *value, size_t valuelen, uint8_t flags, void *user_data) +{ + struct client *cl = (struct client *)user_data; + struct req *req = &cl->req; + const char pathn[] = ":path"; + const char ctn[] = "content-type"; + const char odoh_ct[] = "application/oblivious-dns-message"; + const char doh_ct[] = "application/dns-message"; + (void)session; + (void)flags; + + if (frame->hd.type != NGHTTP2_HEADERS || frame->headers.cat != NGHTTP2_HCAT_REQUEST) + return 0; + + if (frame->hd.stream_id != (int32_t)req->stream_id) + req->stream_id = frame->hd.stream_id; + + if (namelen == strlen(pathn) && memcmp(name, pathn, namelen) == 0) { + if (valuelen >= sizeof(req->req_path)) + req->status_code = 400; + else { + memcpy(req->req_path, value, valuelen); + req->req_path[valuelen] = '\0'; + req->path_seen = 1; + } + } else if (namelen == strlen(ctn) && memcmp(name, ctn, namelen) == 0) { + if (valuelen == strlen(odoh_ct) && memcmp(value, odoh_ct, valuelen) == 0) { + req->req_type = REQ_TYPE_ODOH; + } else if (valuelen == strlen(doh_ct) && memcmp(value, doh_ct, valuelen) == 0) { + req->req_type = REQ_TYPE_DOH; + } else { + req->status_code = 415; + } + } + + return 0; +} + +static int in_data_cb(nghttp2_session *session, uint8_t flags, + int32_t stream_id, const uint8_t *data, size_t len, void *user_data) +{ + struct client *cl = (struct client *)user_data; + struct req *req = &cl->req; + (void)session; + (void)flags; + + if (stream_id != (int32_t)req->stream_id) + return 0; + + if ((req->body_len + len) > sizeof(req->body)) { + req->status_code = 413; + return 0; + } + + memcpy(req->body + req->body_len, data, len); + req->body_len += (uint32_t)len; + return 0; +} + +static int in_frame_recv_cb(nghttp2_session *session, + const nghttp2_frame *frame, void *user_data) +{ + struct client *cl = (struct client *)user_data; + struct req *req = &cl->req; + nghttp2_nv nva[2]; + nghttp2_data_provider dp; + + if ((frame->hd.type != NGHTTP2_DATA && frame->hd.type != NGHTTP2_HEADERS) || + !(frame->hd.flags & NGHTTP2_FLAG_END_STREAM)) + return 0; + + if (req->status_code == 0 && req->req_type == REQ_TYPE_NONE) + req->status_code = 400; + if (req->status_code == 0 && req->req_type == REQ_TYPE_ODOH) { + if (!req->path_seen || parse_target_from_path((const uint8_t *)req->req_path, + strlen(req->req_path), req->targethost, sizeof(req->targethost), + req->targetpath, sizeof(req->targetpath)) != 0) { + req->status_code = 400; + } + } + if (req->status_code == 0 && req->req_type == REQ_TYPE_DOH && target_count == 0) + req->status_code = 502; + + if (req->status_code == 0) { + struct target_conn *legacy; + uint32_t out_len = BUF_MAX; + req->resp = malloc(out_len); + if (!req->resp) { + req->status_code = 500; + } else { + if (req->req_type == REQ_TYPE_ODOH) { + if (forward_to_dynamic_target(req, req->resp, &out_len) != 0) + req->status_code = 502; + else + req->resp_len = out_len; + } else { + legacy = select_legacy_target(); + if (!legacy || forward_to_upstream(legacy, req, + "application/dns-message", req->resp, &out_len) != 0) { + req->status_code = 502; + } else { + req->resp_len = out_len; + } + } + } + } + + if (req->status_code == 502) { + dohprint(DOH_WARN, "returning 502 to client (req_type=%s)", + req->req_type == REQ_TYPE_ODOH ? "odoh" : + req->req_type == REQ_TYPE_DOH ? "doh" : "unknown"); + } + + if (req->status_code != 0) { + nva[0] = MAKE_NV(":status", "400"); + nva[1] = MAKE_NV("server", "dohproxyd"); + if (req->status_code == 403) nva[0] = MAKE_NV(":status", "403"); + if (req->status_code == 413) nva[0] = MAKE_NV(":status", "413"); + if (req->status_code == 415) nva[0] = MAKE_NV(":status", "415"); + if (req->status_code == 500) nva[0] = MAKE_NV(":status", "500"); + if (req->status_code == 502) nva[0] = MAKE_NV(":status", "502"); + nghttp2_submit_response(session, req->stream_id, nva, 2, NULL); + } else { + const char *response_ct = (req->req_type == REQ_TYPE_ODOH) ? + "application/oblivious-dns-message" : "application/dns-message"; + nghttp2_nv oknva[] = { + MAKE_NV(":status", "200"), + MAKE_NV("content-type", response_ct), + MAKE_NV("server", "dohproxyd"), + }; + memset(&dp, 0, sizeof(dp)); + dp.source.ptr = req; + dp.read_callback = in_resp_read_cb; + nghttp2_submit_response(session, req->stream_id, oknva, 3, &dp); + } + + nghttp2_session_send(session); + free(req->resp); + memset(req, 0, sizeof(*req)); + return 0; +} + +static int in_stream_close_cb(nghttp2_session *session, int32_t stream_id, + uint32_t error_code, void *user_data) +{ + (void)session; + (void)stream_id; + (void)error_code; + (void)user_data; + return 0; +} + +static void client_read(int fd, short revents, void *arg) +{ + struct client *cl = (struct client *)arg; + uint8_t buf[8192]; + int ret; + + (void)fd; + (void)revents; + + if (!cl || !cl->ssl) + return; + + if (!cl->tls_done) { + ret = wolfSSL_accept(cl->ssl); + if (ret != SSL_SUCCESS) { + int err = wolfSSL_get_error(cl->ssl, ret); + if (err == WOLFSSL_ERROR_WANT_READ || err == WOLFSSL_ERROR_WANT_WRITE) + return; + free_client(cl); + return; + } + + nghttp2_session_callbacks *cbs = NULL; + nghttp2_settings_entry iv[1] = {{ NGHTTP2_SETTINGS_MAX_CONCURRENT_STREAMS, 100 }}; + + if (nghttp2_session_callbacks_new(&cbs) != 0) { + free_client(cl); + return; + } + nghttp2_session_callbacks_set_send_callback(cbs, in_send_cb); + nghttp2_session_callbacks_set_on_header_callback(cbs, in_header_cb); + nghttp2_session_callbacks_set_on_data_chunk_recv_callback(cbs, in_data_cb); + nghttp2_session_callbacks_set_on_frame_recv_callback(cbs, in_frame_recv_cb); + nghttp2_session_callbacks_set_on_stream_close_callback(cbs, in_stream_close_cb); + nghttp2_session_server_new(&cl->h2, cbs, cl); + nghttp2_session_callbacks_del(cbs); + nghttp2_submit_settings(cl->h2, NGHTTP2_FLAG_NONE, iv, 1); + cl->tls_done = 1; + return; + } + + ret = wolfSSL_read(cl->ssl, buf, sizeof(buf)); + if (ret <= 0) { + free_client(cl); + return; + } + + if (nghttp2_session_mem_recv(cl->h2, buf, (size_t)ret) < 0) { + free_client(cl); + return; + } + + while (nghttp2_session_want_write(cl->h2)) { + if (nghttp2_session_send(cl->h2) < 0) { + free_client(cl); + return; + } + } +} + +static void client_fail(int fd, short revents, void *arg) +{ + (void)fd; + (void)revents; + free_client((struct client *)arg); +} + +static void accept_client(int fd, short revents, void *arg) +{ + int cfd; + int yes = 1; + socklen_t sl = 0; + struct client *cl; + + (void)fd; + (void)revents; + (void)arg; + + cfd = accept(lfd, NULL, &sl); + if (cfd < 0) + return; + + cl = calloc(1, sizeof(*cl)); + if (!cl) { + close(cfd); + return; + } + + cl->fd = cfd; + cl->ssl = wolfSSL_new(srv_ctx); + if (!cl->ssl) { + free(cl); + close(cfd); + return; + } + + setsockopt(cfd, IPPROTO_TCP, TCP_NODELAY, (char *)&yes, sizeof(yes)); + wolfSSL_UseALPN(cl->ssl, "h2", 2, WOLFSSL_ALPN_CONTINUE_ON_MISMATCH); + wolfSSL_set_fd(cl->ssl, cfd); + + cl->ev = evquick_addevent(cfd, EVQUICK_EV_READ, client_read, client_fail, cl); + cl->next = clients; + clients = cl; +} + +static void usage(const char *name) +{ + fprintf(stderr, "%s, ODoH proxy with legacy RFC8484 forwarding.\n", name); + fprintf(stderr, "Usage: %s -c cert -k key [-p port] [-4|-6] [-u user] [-A cafile] [--target-url https://host/path]... [--targets-file file] [--target-cert cert --target-key key] [-F] [-v] [-V] [-h]\n", name); + fprintf(stderr, " cert/key: TLS server certificate and key\n"); + fprintf(stderr, " -4: force IPv4 only\n"); + fprintf(stderr, " -6: force IPv6 only (default: dual-stack)\n"); + fprintf(stderr, " -A/--ca-file: CA bundle for verifying upstream target TLS certs\n"); + fprintf(stderr, " --target-url: repeatable legacy RFC8484 target URL\n"); + fprintf(stderr, " --targets-file: file with target URLs (one per line)\n"); + fprintf(stderr, " target selection for legacy requests follows RFC-style random rotation\n"); + fprintf(stderr, " --target-cert/--target-key: mTLS cert/key for target resolver\n"); +} + +int main(int argc, char *argv[]) +{ + char *cert = NULL, *key = NULL; + char *user = NULL; + char *targets_file = NULL; + char *upstream_cafile = NULL; + uint16_t port = PROXY_PORT; + int ip_version = 0; /* 0 = dual-stack, 4 = IPv4 only, 6 = IPv6 only */ + int foreground = 0; + int loglvl = DOH_WARN; + int c, option_idx = 0; + int yes = 1; + struct sockaddr_in6 addr6; + struct sockaddr_in addr4; + struct option long_options[] = { + {"help", 0, 0, 'h'}, + {"version", 0, 0, 'V'}, + {"cert", 1, 0, 'c'}, + {"key", 1, 0, 'k'}, + {"port", 1, 0, 'p'}, + {"user", 1, 0, 'u'}, + {"verbose", 0, 0, 'v'}, + {"do-not-fork", 0, 0, 'F'}, + {"ca-file", 1, 0, 'A'}, + {"target-cert", 1, 0, 'x'}, + {"target-key", 1, 0, 'y'}, + {"target-url", 1, 0, 't'}, + {"targets-file", 1, 0, 'T'}, + {"ipv4", 0, 0, '4'}, + {"ipv6", 0, 0, '6'}, + {NULL, 0, 0, 0} + }; + + while (1) { + c = getopt_long(argc, argv, "46hVc:k:p:u:A:vFx:y:t:T:", long_options, &option_idx); + if (c < 0) + break; + + switch (c) { + case 'h': usage(argv[0]); return 0; + case 'V': fprintf(stderr, "%s, %s\n", argv[0], VERSION); return 0; + case 'c': cert = strdup(optarg); break; + case 'k': key = strdup(optarg); break; + case 'p': port = (uint16_t)atoi(optarg); break; + case 'u': user = strdup(optarg); break; + case 'A': upstream_cafile = strdup(optarg); break; + case 'v': loglvl = DOH_DEBUG; break; + case 'F': foreground = 1; break; + case 'x': target_client_cert = strdup(optarg); break; + case 'y': target_client_key = strdup(optarg); break; + case '4': ip_version = 4; break; + case '6': ip_version = 6; break; + case 't': + if (add_target_url(optarg) != 0) + return 2; + break; + case 'T': + free(targets_file); + targets_file = strdup(optarg); + break; + default: usage(argv[0]); return 2; + } + } + + if (!cert || !key) + return 2; + if ((target_client_cert && !target_client_key) || + (!target_client_cert && target_client_key)) + return 2; + if (targets_file && load_targets_file(targets_file) != 0) + return 2; + + if (!foreground) { + int pid = fork(); + if (pid < 0) return 1; + if (pid > 0) return 0; + pid = fork(); + if (pid < 0) return 1; + if (pid > 0) return 0; + setsid(); + close(STDIN_FILENO); + close(STDOUT_FILENO); + close(STDERR_FILENO); + } + + signal(SIGPIPE, SIG_IGN); + dohprint_init(foreground, loglvl); + srand((unsigned)time(NULL)); + + wolfSSL_Init(); + evquick_init(); + + srv_ctx = wolfSSL_CTX_new(wolfTLSv1_3_server_method()); + cli_ctx = wolfSSL_CTX_new(wolfTLS_client_method()); + if (!srv_ctx || !cli_ctx) + return 1; + + wolfSSL_CTX_set_verify(cli_ctx, WOLFSSL_VERIFY_PEER, NULL); + if (upstream_cafile) { + if (wolfSSL_CTX_load_verify_locations(cli_ctx, upstream_cafile, NULL) != SSL_SUCCESS) + return 1; + } else { + if (wolfSSL_CTX_load_system_CA_certs(cli_ctx) != SSL_SUCCESS) + wolfSSL_CTX_set_default_verify_paths(cli_ctx); + } + + if (wolfSSL_CTX_use_certificate_chain_file(srv_ctx, cert) != SSL_SUCCESS) + return 1; + if (wolfSSL_CTX_use_PrivateKey_file(srv_ctx, key, SSL_FILETYPE_PEM) != SSL_SUCCESS) + return 1; + + /* Create listening socket based on IP version preference */ + if (ip_version == 4) { + /* IPv4 only */ + lfd = socket(AF_INET, SOCK_STREAM, 0); + if (lfd < 0) + return 1; + setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, (char *)&yes, sizeof(yes)); + setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, (char *)&yes, sizeof(yes)); + + memset(&addr4, 0, sizeof(addr4)); + addr4.sin_family = AF_INET; + addr4.sin_addr.s_addr = INADDR_ANY; + addr4.sin_port = htons(port); + + if (bind(lfd, (struct sockaddr *)&addr4, sizeof(addr4)) != 0) + return 1; + dohprint(DOH_NOTICE, "dohproxyd listening on 0.0.0.0:%u (IPv4 only)", port); + } else { + /* IPv6 (with or without dual-stack) */ + lfd = socket(AF_INET6, SOCK_STREAM, 0); + if (lfd < 0) + return 1; + setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, (char *)&yes, sizeof(yes)); + setsockopt(lfd, SOL_SOCKET, SO_REUSEPORT, (char *)&yes, sizeof(yes)); + + if (ip_version == 6) { + /* IPv6 only - disable dual-stack */ + int ipv6only = 1; + setsockopt(lfd, IPPROTO_IPV6, IPV6_V6ONLY, &ipv6only, sizeof(ipv6only)); + } + + memset(&addr6, 0, sizeof(addr6)); + addr6.sin6_family = AF_INET6; + addr6.sin6_addr = in6addr_any; + addr6.sin6_port = htons(port); + + if (bind(lfd, (struct sockaddr *)&addr6, sizeof(addr6)) != 0) + return 1; + + if (ip_version == 6) + dohprint(DOH_NOTICE, "dohproxyd listening on [::]:%u (IPv6 only)", port); + else + dohprint(DOH_NOTICE, "dohproxyd listening on [::]:%u (dual-stack)", port); + } + + if (listen(lfd, 32) != 0) + return 1; + + if (getuid() == 0 && user) { + struct passwd *pwd = getpwnam(user); + if (pwd) { + if (setgid(pwd->pw_gid) != 0 || setuid(pwd->pw_uid) != 0) + return 1; + } + } + + evquick_addevent(lfd, EVQUICK_EV_READ, accept_client, NULL, NULL); + + while (run) + evquick_loop(); + + if (targets) { + size_t i; + for (i = 0; i < target_count; i++) + close_target_connection(&targets[i]); + free(targets); + } + free(targets_file); + free(target_client_cert); + free(target_client_key); + return 0; +} diff --git a/src/Makefile b/src/Makefile index ba7af53..63c8a70 100644 --- a/src/Makefile +++ b/src/Makefile @@ -22,7 +22,7 @@ asan: CFLAGS+=-fsanitize=address asan: LDFLAGS+=-fsanitize=address asan: dohd -dohd: url64.o libevquick.o mempool.o dohd.o +dohd: url64.o libevquick.o mempool.o odoh.o proxy_auth.o h2_session.o dohd.o gcc -o $@ $^ $(LDFLAGS) clean: rm -f *.o dohd diff --git a/src/dohd.c b/src/dohd.c index f57f09a..1b8b44d 100644 --- a/src/dohd.c +++ b/src/dohd.c @@ -35,8 +35,12 @@ #include "libevquick.h" #include "url64.h" #include "mempool.h" +#include "odoh.h" +#include "proxy_auth.h" +#include "h2_session.h" #include #include +#include #ifdef DMALLOC #include "dmalloc.h" @@ -118,6 +122,7 @@ static struct doh_stats { uint64_t http2_post_requests; uint64_t http2_get_requests; uint64_t socket_errors; + uint64_t pool_exhausted; /* Memory (current, peak) */ uint64_t mem; @@ -223,6 +228,7 @@ static void printstats(void) dohprint(LOG_NOTICE, "- Failures:"); dohprint(LOG_NOTICE, " - Invalid HTTP requests: %lu", DOH_Stats.http_notvalid_requests); dohprint(LOG_NOTICE, " - Socket errors: %lu", DOH_Stats.socket_errors); + dohprint(LOG_NOTICE, " - Pool exhausted: %lu", DOH_Stats.pool_exhausted); dohprint(LOG_NOTICE, "- Memory usage:"); dohprint(LOG_NOTICE, " - Current: %lu Bytes, peak: %lu Bytes", DOH_Stats.mem, DOH_Stats.mem_peak); dohprint(LOG_NOTICE, "- Connected clients:"); @@ -252,6 +258,13 @@ struct __attribute__((packed)) dns_header static int lfd = -1; static WOLFSSL_CTX *wctx = NULL; +static int oblivion_mode = 0; +static char *odoh_secret_file = NULL; +static char *odoh_config_file = NULL; +static char *authorized_proxy_dir = NULL; +static char *resolved_proxy_dir = NULL; +static odoh_target_ctx odoh_target = {}; +static proxy_auth_set proxy_set = {}; #ifndef _MUSL_ void *sigset(int sig, void (*disp)(int)); #endif @@ -267,6 +280,10 @@ struct req_slot { uint32_t h2_request_len; uint8_t *h2_response_data; uint32_t h2_response_len; + int is_odoh; + int content_type_seen; + int is_h2_get; + odoh_req_ctx odoh_ctx; uint16_t id; struct sockaddr *resolver; socklen_t resolver_sz; @@ -355,8 +372,76 @@ static void dohd_listen_error(int __attribute__((unused)) fd, exit(80); } +static int verify_always_ok(int preverify, WOLFSSL_X509_STORE_CTX *store) +{ + (void)preverify; + (void)store; + return 1; +} + +static char *resolve_proxy_dir_path(const char *path, const char *target_user) +{ + const char *home = NULL; + struct passwd *pw = NULL; + char *out; + size_t out_len; + + if (!path) + return NULL; + + if (path[0] != '~') + return strdup(path); + + if (target_user && target_user[0] != '\0') { + pw = getpwnam(target_user); + if (!pw || !pw->pw_dir) + return NULL; + home = pw->pw_dir; + } else { + pw = getpwuid(getuid()); + if (!pw || !pw->pw_dir) + return NULL; + home = pw->pw_dir; + } + + out_len = strlen(home) + strlen(path); + out = malloc(out_len + 1); + if (!out) + return NULL; + snprintf(out, out_len + 1, "%s%s", home, path + 1); + return out; +} + +static int reload_proxy_authorization(void) +{ + proxy_auth_set newset = {}; + if (!resolved_proxy_dir) + return -1; + if (proxy_auth_load_dir(resolved_proxy_dir, &newset) != 0) + return -1; + proxy_auth_free(&proxy_set); + proxy_set = newset; + return 0; +} + +static int reload_odoh_target(void) +{ + odoh_target_ctx next = {}; + if (!odoh_config_file || !odoh_secret_file) + return -1; + if (odoh_target_load_files(odoh_config_file, odoh_secret_file, &next) != 0) + return -1; + odoh_target_free(&odoh_target); + odoh_target = next; + return 0; +} + static void sig_stats(int __attribute__((unused)) signo) { + if (oblivion_mode) { + reload_proxy_authorization(); + reload_odoh_target(); + } printstats(); } @@ -465,6 +550,10 @@ static void clean_exit(int __attribute__((unused)) signo) mempool_destroy(request_pool); client_pool = NULL; request_pool = NULL; + proxy_auth_free(&proxy_set); + odoh_target_free(&odoh_target); + free(resolved_proxy_dir); + resolved_proxy_dir = NULL; fprintf(stderr, "Cleanup, exiting...\n"); #ifdef DMALLOC @@ -531,6 +620,10 @@ struct req_slot *dns_create_request_h2(struct client_data *cd, uint32_t stream_i req->owner_fd = cd->doh_sd; req->h2_stream_id = stream_id; req->timeout_timer = NULL; + req->is_odoh = 0; + req->content_type_seen = 0; + req->is_h2_get = 0; + memset(&req->odoh_ctx, 0, sizeof(req->odoh_ctx)); nghttp2_session_set_stream_user_data(cd->h2_session, stream_id, req); return req; } @@ -570,7 +663,22 @@ static void dns_request_timeout(void *arg) static int dns_send_request_h2(struct req_slot *req) { int ret; + uint8_t plain_dns[DNS_BUFFER_MAXSIZE]; + uint16_t plain_len = 0; struct dns_header *hdr; + if (oblivion_mode) { + if (!req->is_odoh) + return -1; + if (odoh_target_decrypt_query(&odoh_target, + req->h2_request_buffer, (uint16_t)req->h2_request_len, + plain_dns, &plain_len, &req->odoh_ctx) != 0) { + return -1; + } + if (plain_len > sizeof(req->h2_request_buffer)) + return -1; + memcpy(req->h2_request_buffer, plain_dns, plain_len); + req->h2_request_len = plain_len; + } /* Parse DNS header: only check the qr flag. */ hdr = (struct dns_header *)req->h2_request_buffer; if (hdr->qr != 0) { @@ -578,6 +686,14 @@ static int dns_send_request_h2(struct req_slot *req) } req->ev_dns = evquick_addevent(req->dns_sd, EVQUICK_EV_READ, dohd_reply, NULL, req); + if (!req->ev_dns) { + /* Event registration failed - destroy request immediately */ + dohprint(DOH_ERR, "Failed to register DNS socket event"); + DOH_Stats.socket_errors++; + check_stats(); + dohd_destroy_request(req); + return -1; + } ret = sendto(req->dns_sd, req->h2_request_buffer, req->h2_request_len, 0, (struct sockaddr *)req->resolver, req->resolver_sz); if (ret < 0) { @@ -848,22 +964,35 @@ static void dohd_reply(int fd, short __attribute__((unused)) revents, if (cd->h2 != 0) { const char max_age_tmpl[] = "max-age=%d"; char max_age_txt[15]; + const char *ctype = "application/dns-message"; + const uint8_t *resp_ptr = buff; + uint8_t odoh_buf[ODOH_MAX_MESSAGE]; + uint16_t odoh_len = 0; + uint32_t resp_len = (uint32_t)len; snprintf(max_age_txt, 15, max_age_tmpl, age); + if (oblivion_mode && req->is_odoh) { + if (odoh_target_encrypt_response(&req->odoh_ctx, + buff, (uint16_t)len, odoh_buf, &odoh_len) != 0) + goto destroy; + ctype = "application/oblivious-dns-message"; + resp_ptr = odoh_buf; + resp_len = odoh_len; + } nghttp2_nv nva[] = { MAKE_NV(":status", "200"), - MAKE_NV("content-type", "application/dns-message"), + MAKE_NV("content-type", ctype), MAKE_NV("server", "dohd"), MAKE_NV("cache-control", max_age_txt), }; - req->h2_response_data = malloc(len); + req->h2_response_data = malloc(resp_len); check_stats(); if (!req->h2_response_data) { dohprint(DOH_ERR, "Out-of-memory!\n"); exit(1); } - DOH_Stats.mem += len; - memcpy(req->h2_response_data, buff, len); - req->h2_response_len = len; + DOH_Stats.mem += resp_len; + memcpy(req->h2_response_data, resp_ptr, resp_len); + req->h2_response_len = resp_len; memset(&data_prd, 0, sizeof(data_prd)); data_prd.source.ptr = req; data_prd.read_callback = h2_cb_req_submit; @@ -890,13 +1019,7 @@ static ssize_t h2_cb_send(nghttp2_session *session, const uint8_t *data, struct client_data *cd = (struct client_data *)user_data; (void)session; (void)flags; - int ret; - ret = client_ssl_write(cd, data, length); - - if (nghttp2_session_want_write(session)) { - nghttp2_session_send(session); - } - return ret; + return client_ssl_write(cd, data, length); } @@ -946,9 +1069,25 @@ static int h2_cb_on_frame_recv(nghttp2_session *session, if ((!req)) { return 0; } - if (req->h2_request_len > 0) - dns_send_request_h2(req); - else + if ((!req->content_type_seen && !req->is_h2_get) || req->is_odoh < 0 || + (oblivion_mode && req->is_odoh == 0) || + (!oblivion_mode && req->is_odoh == 1)) { + nghttp2_nv nva[] = { + MAKE_NV(":status", "415"), + MAKE_NV("server", "dohd"), + }; + nghttp2_submit_response(session, frame->hd.stream_id, nva, 2, NULL); + dohd_destroy_request(req); + } else if (req->h2_request_len > 0) { + if (dns_send_request_h2(req) < 0) { + /* Request already destroyed by dns_send_request_h2, send error */ + nghttp2_nv nva[] = { + MAKE_NV(":status", "500"), + MAKE_NV("server", "dohd"), + }; + nghttp2_submit_response(session, frame->hd.stream_id, nva, 2, NULL); + } + } else dohd_destroy_request(req); } break; @@ -967,12 +1106,9 @@ static int h2_cb_on_stream_close(nghttp2_session *session, int32_t stream_id, struct client_data *cd = (struct client_data *)user_data; struct req_slot *req = nghttp2_session_get_stream_user_data(session, stream_id); - if (!req) - return -1; - if (cd != req->owner) - return -1; (void)error_code; - if (req) { + if (dohd_h2_stream_close_action(req != NULL, + req != NULL && cd == req->owner) == DOHD_H2_STREAM_CLOSE_DESTROY) { dohd_destroy_request(req); } return 0; @@ -997,6 +1133,9 @@ static int h2_cb_on_header(nghttp2_session *session, { const char PATH[] = ":path"; + const char CONTENT_TYPE[] = "content-type"; + const char CT_DNS[] = "application/dns-message"; + const char CT_ODOH[] = "application/oblivious-dns-message"; const char GETDNS[] = "/?dns="; struct client_data *cd = (struct client_data *)user_data; struct req_slot *req; @@ -1028,21 +1167,37 @@ static int h2_cb_on_header(nghttp2_session *session, if (valuelen > strlen(GETDNS) && (strncmp((char*)value, GETDNS, strlen(GETDNS)) == 0) && (valuelen < DNS_BUFFER_MAXSIZE)) { uint32_t outlen = DNS_BUFFER_MAXSIZE; + size_t b64len = valuelen - strlen(GETDNS); + char b64tmp[DNS_BUFFER_MAXSIZE]; + memcpy(b64tmp, value + strlen(GETDNS), b64len); + b64tmp[b64len] = '\0'; req->h2_request_len = 0; - if(dohd_url64_check((const char*)(value + 6)) == 0) { + if(dohd_url64_check(b64tmp) == 0) { dohd_destroy_request(req); return 0; } - outlen = dohd_url64_decode((const char*)(value + 6), - req->h2_request_buffer); + outlen = dohd_url64_decode(b64tmp, req->h2_request_buffer); if (outlen <= 0) { dohd_destroy_request(req); return 0; } req->h2_request_len = outlen; + req->is_h2_get = 1; DOH_Stats.http2_get_requests++; check_stats(); } + } else if ((namelen == strlen(CONTENT_TYPE)) && + memcmp(CONTENT_TYPE, name, namelen) == 0) { + req->content_type_seen = 1; + if (valuelen == strlen(CT_ODOH) && + memcmp(value, CT_ODOH, valuelen) == 0) { + req->is_odoh = 1; + } else if (valuelen == strlen(CT_DNS) && + memcmp(value, CT_DNS, valuelen) == 0) { + req->is_odoh = 0; + } else { + req->is_odoh = -1; + } } break; } @@ -1070,10 +1225,15 @@ static void tls_read(__attribute__((unused)) int fd, short __attribute__((unused int err = wolfSSL_get_error(cd->ssl, ret); if (err == WOLFSSL_ERROR_WANT_READ || err == WOLFSSL_ERROR_WANT_WRITE) return; + dohprint(DOH_DEBUG, "TLS handshake failed: error %d", err); dohd_destroy_client(cd); } else { uint16_t proto_len; char *proto; + if (oblivion_mode && !proxy_auth_peer_allowed(cd->ssl, &proxy_set)) { + dohd_destroy_client(cd); + return; + } if (wolfSSL_ALPN_GetProtocol(cd->ssl, &proto, &proto_len) && (2 == proto_len) && strncmp(proto, "h2", 2) == 0) { nghttp2_settings_entry iv[1] = { @@ -1100,6 +1260,9 @@ static void tls_read(__attribute__((unused)) int fd, short __attribute__((unused /* Read the client data into our buff array */ ret = wolfSSL_read(cd->ssl, buff, DNS_BUFFER_MAXSIZE); if (ret < 0) { + int err = wolfSSL_get_error(cd->ssl, ret); + if (err == WOLFSSL_ERROR_WANT_READ || err == WOLFSSL_ERROR_WANT_WRITE) + return; /* Non-blocking - wait for more data */ dohd_destroy_client(cd); DOH_Stats.socket_errors++; } else if (ret == 0) { @@ -1110,12 +1273,15 @@ static void tls_read(__attribute__((unused)) int fd, short __attribute__((unused readlen = nghttp2_session_mem_recv(cd->h2_session, buff, ret); if (readlen < 0) { dohprint(DOH_WARN, "NGHTTP2 error: %s\n", nghttp2_strerror((int)readlen)); + dohd_destroy_client(cd); return; } while (nghttp2_session_want_write(cd->h2_session)) { ret = nghttp2_session_send(cd->h2_session); if (ret < 0) { dohprint(DOH_WARN, "NGHTTP2 error: %s\n", nghttp2_strerror((int)ret)); + dohd_destroy_client(cd); + return; } } } else { @@ -1158,17 +1324,18 @@ static void dohd_new_connection(int __attribute__((unused)) fd, int ret; #endif - cd = mempool_alloc(client_pool); - if (cd == NULL) { - dohprint(DOH_ERR, "Failed to allocate memory for a new connection\n\n"); - return; - } - - /* Accept client connections */ + /* Accept client connections first to avoid leaving them in listen queue */ connd = accept(lfd, NULL, &zero); if (connd < 0) { dohprint(DOH_WARN, "Failed to accept the connection: %s\n\n", strerror(errno)); - mempool_free(client_pool, cd); + return; + } + + cd = mempool_alloc(client_pool); + if (cd == NULL) { + dohprint(DOH_ERR, "Client pool exhausted, rejecting connection"); + close(connd); + DOH_Stats.pool_exhausted++; return; } #ifdef OCSP_RESPONDER @@ -1185,6 +1352,9 @@ static void dohd_new_connection(int __attribute__((unused)) fd, setsockopt(connd, IPPROTO_TCP, TCP_NODELAY, (char *) &yes, sizeof(int)); setsockopt(connd, SOL_SOCKET, SO_REUSEADDR, (char *) &yes, sizeof(int)); + /* Set socket to non-blocking for async TLS handshake */ + fcntl(connd, F_SETFL, fcntl(connd, F_GETFL, 0) | O_NONBLOCK); + /* Create a WOLFSSL object */ cd->ssl = wolfSSL_new(wctx); if (cd->ssl == NULL) { @@ -1204,6 +1374,13 @@ static void dohd_new_connection(int __attribute__((unused)) fd, cd->doh_sd = connd; cd->ev_doh = evquick_addevent(cd->doh_sd, EVQUICK_EV_READ, tls_read, tls_fail, cd); + if (!cd->ev_doh) { + dohprint(DOH_ERR, "ERROR: failed to register client event"); + wolfSSL_free(cd->ssl); + close(connd); + mempool_free(client_pool, cd); + return; + } /* Insert into hash table - O(1) */ client_hash_insert(cd); @@ -1220,7 +1397,7 @@ static void usage(const char *name) fprintf(stderr, "%s, DNSoverHTTPS minimalist daemon.\n", name); fprintf(stderr, "License: AGPL\n"); - fprintf(stderr, "Usage: %s -c cert -k key [-p port] [-d dnsserver] [-F] [-u user] [-V] [-v] [-h]\n", name); + fprintf(stderr, "Usage: %s -c cert -k key [-p port] [-d dnsserver] [-F] [-u user] [-O --odoh-config file --odoh-secret file] [-V] [-v] [-h]\n", name); fprintf(stderr, "\t'cert' and 'key': certificate and its private key.\n"); fprintf(stderr, "\t'user' : login name (when running as root) to switch to (dropping permissions)\n"); fprintf(stderr, "\tDefault values: port=8053 dnsserver=\"::1\"\n"); @@ -1228,6 +1405,7 @@ static void usage(const char *name) fprintf(stderr, "\tUse '-V' to show version\n"); fprintf(stderr, "\tUse '-v' for verbose mode\n"); fprintf(stderr, "\tUse '-F' for foreground mode\n"); + fprintf(stderr, "\tUse '-O' for Oblivious DoH target mode\n"); exit(0); } @@ -1251,12 +1429,16 @@ int main(int argc, char *argv[]) {"port", 1, 0, 'p'}, {"dnsserver", 1, 0, 'd'}, {"user", 1, 0, 'u'}, + {"oblivion", 0, 0, 'O'}, + {"odoh-config", 1, 0, 'C'}, + {"odoh-secret", 1, 0, 'S'}, + {"authorized-proxies-dir", 1, 0, 'P'}, {"verbose", 0, 0, 'v'}, {"do-not-fork", 0, 0, 'F'}, {NULL, 0, 0, '\0' } }; while(1) { - c = getopt_long(argc, argv, "hvVc:k:p:d:u:F" , long_options, &option_idx); + c = getopt_long(argc, argv, "hvVOc:k:p:d:u:C:S:P:F" , long_options, &option_idx); if (c < 0) break; switch(c) { @@ -1321,6 +1503,21 @@ int main(int argc, char *argv[]) user = strdup(optarg); } break; + case 'O': + oblivion_mode = 1; + break; + case 'C': + free(odoh_config_file); + odoh_config_file = strdup(optarg); + break; + case 'S': + free(odoh_secret_file); + odoh_secret_file = strdup(optarg); + break; + case 'P': + free(authorized_proxy_dir); + authorized_proxy_dir = strdup(optarg); + break; case 'F': foreground = 1; break; @@ -1336,6 +1533,19 @@ int main(int argc, char *argv[]) if (!cert || !key) usage(argv[0]); + if (oblivion_mode) { + if (!odoh_config_file || !odoh_secret_file) + usage(argv[0]); + if (!authorized_proxy_dir) + authorized_proxy_dir = strdup("~/.config/dohd/proxies/"); + resolved_proxy_dir = resolve_proxy_dir_path(authorized_proxy_dir, user); + if (!resolved_proxy_dir || reload_proxy_authorization() != 0 || + reload_odoh_target() != 0) { + fprintf(stderr, "Error initializing ODoH files and authorized proxies directory\n"); + exit(2); + } + } + if (!foreground) { int pid = fork(); if (pid > 0) @@ -1352,6 +1562,7 @@ int main(int argc, char *argv[]) /* Set SIGUSR1 */ sigset(SIGUSR1, sig_stats); + sigset(SIGHUP, sig_stats); /* Set SIGINT */ sigset(SIGINT, clean_exit); @@ -1469,6 +1680,12 @@ int main(int argc, char *argv[]) } dohprint(DOH_DEBUG, "SSL context initialized (TLS 1.3)"); + if (oblivion_mode) { + wolfSSL_CTX_set_verify(wctx, + WOLFSSL_VERIFY_PEER | WOLFSSL_VERIFY_FAIL_IF_NO_PEER_CERT, + verify_always_ok); + } + /* Enable session tickets for faster reconnection (1-RTT resume) */ wolfSSL_CTX_set_session_cache_mode(wctx, WOLFSSL_SESS_CACHE_SERVER); wolfSSL_CTX_set_timeout(wctx, 3600); /* 1 hour session timeout */ @@ -1489,8 +1706,8 @@ int main(int argc, char *argv[]) dohprint(DOH_DEBUG, "Private key correctly imported"); - /* Listen for a new connection, allow 10 pending connections */ - if (listen(lfd, 10) == -1) { + /* Listen for a new connection, allow larger backlog for burst handling */ + if (listen(lfd, 128) == -1) { dohprint(LOG_ERR, "ERROR: failed to listen\n"); return -1; } diff --git a/src/h2_session.c b/src/h2_session.c new file mode 100644 index 0000000..d9f3430 --- /dev/null +++ b/src/h2_session.c @@ -0,0 +1,10 @@ +#include "h2_session.h" + +enum dohd_h2_stream_close_action dohd_h2_stream_close_action(int has_request, + int owner_matches) +{ + if (!has_request || !owner_matches) + return DOHD_H2_STREAM_CLOSE_IGNORE; + + return DOHD_H2_STREAM_CLOSE_DESTROY; +} diff --git a/src/h2_session.h b/src/h2_session.h new file mode 100644 index 0000000..a4ec295 --- /dev/null +++ b/src/h2_session.h @@ -0,0 +1,12 @@ +#ifndef DOHD_H2_SESSION_H +#define DOHD_H2_SESSION_H + +enum dohd_h2_stream_close_action { + DOHD_H2_STREAM_CLOSE_IGNORE = 0, + DOHD_H2_STREAM_CLOSE_DESTROY = 1, +}; + +enum dohd_h2_stream_close_action dohd_h2_stream_close_action(int has_request, + int owner_matches); + +#endif diff --git a/src/heap.h b/src/heap.h index 37a37a2..fd3f0a8 100644 --- a/src/heap.h +++ b/src/heap.h @@ -32,12 +32,13 @@ static inline int heap_insert(struct heap_##type *heap, type *el) struct heap_element_##type etmp; \ memcpy(&etmp.data, el, sizeof(type)); \ if (++heap->n >= heap->size) { \ - heap->top = realloc(heap->top, \ + struct heap_element_##type *_tmp = realloc(heap->top, \ (heap->n + 1) * sizeof(struct heap_element_##type)); \ - if (!heap->top) { \ + if (!_tmp) { \ heap->n--; \ return -1; \ } \ + heap->top = _tmp; \ heap->size++; \ } \ etmp.id = heap->last_id++; \ diff --git a/src/libevquick.c b/src/libevquick.c index a60f2b4..e3ee92d 100644 --- a/src/libevquick.c +++ b/src/libevquick.c @@ -348,11 +348,15 @@ CTX evquick_init(void) if (!ctx) return NULL; ctx->giveup = 0; + ctx->epfd = -1; + ctx->time_machine[0] = -1; + ctx->time_machine[1] = -1; + ctx->timers = heap_init(); if (!ctx->timers) - return NULL; + goto fail; if(pipe(ctx->time_machine) < 0) - return NULL; + goto fail; (void)yes; fcntl(ctx->time_machine[1], F_SETFL, O_NONBLOCK); @@ -360,7 +364,7 @@ CTX evquick_init(void) ctx->epfd = epoll_create1(0); if (ctx->epfd < 0) { perror("epoll_create1"); - return NULL; + goto fail; } /* Add time_machine pipe to epoll for timer wakeups */ @@ -368,8 +372,7 @@ CTX evquick_init(void) ev.data.ptr = NULL; /* NULL ptr indicates time_machine */ if (epoll_ctl(ctx->epfd, EPOLL_CTL_ADD, ctx->time_machine[0], &ev) < 0) { perror("epoll_ctl time_machine"); - close(ctx->epfd); - return NULL; + goto fail; } ctx->n_events = 1; @@ -378,11 +381,23 @@ CTX evquick_init(void) act.sa_flags = SA_NODEFER; if (sigaction(SIGALRM, &act, NULL) < 0) { perror("Setting alarm signal"); - return NULL; + goto fail; } ctx_add(ctx); timer_new(ctx); return ctx; + +fail: + if (ctx->epfd >= 0) + close(ctx->epfd); + if (ctx->time_machine[0] >= 0) + close(ctx->time_machine[0]); + if (ctx->time_machine[1] >= 0) + close(ctx->time_machine[1]); + if (ctx->timers) + heap_destroy(ctx->timers); + free(ctx); + return NULL; } @@ -456,7 +471,10 @@ void evquick_loop(void) /* NULL ptr means time_machine pipe for timer wakeups */ if (e == NULL) { char discard; - read(ctx->time_machine[0], &discard, 1); + if (read(ctx->time_machine[0], &discard, 1) < 0) { + if (errno != EINTR && errno != EAGAIN) + perror("time_machine read"); + } timer_check(ctx); continue; } diff --git a/src/odoh.c b/src/odoh.c new file mode 100644 index 0000000..6fc8e11 --- /dev/null +++ b/src/odoh.c @@ -0,0 +1,730 @@ +#include "odoh.h" + +#include +#include +#include + +#include +#include +#include +#include +#include + +#define ODOH_VERSION 0x0001 +#define ODOH_INFO_QUERY "odoh query" +#define ODOH_LABEL_KEYID "odoh key id" +#define ODOH_LABEL_RESPONSE "odoh response" +#define ODOH_LABEL_KEY "odoh key" +#define ODOH_LABEL_NONCE "odoh nonce" + +#if defined(LIBWOLFSSL_VERSION_HEX) && (LIBWOLFSSL_VERSION_HEX >= 0x05008000) +#define ODOH_HAVE_HPKE_CONTEXT_API 1 +#else +#define ODOH_HAVE_HPKE_CONTEXT_API 0 +#endif + +static int odoh_hpke_init_seal_context(Hpke *hpke, HpkeBaseContext *ctx, + void *eph, void *receiver, byte *info, word32 info_sz) +{ +#if ODOH_HAVE_HPKE_CONTEXT_API + return wc_HpkeInitSealContext(hpke, ctx, eph, receiver, info, info_sz); +#else + (void)hpke; (void)ctx; (void)eph; (void)receiver; (void)info; (void)info_sz; + return -1; +#endif +} + +static int odoh_hpke_context_seal_base(Hpke *hpke, HpkeBaseContext *ctx, + byte *aad, word32 aad_sz, byte *pt, word32 pt_sz, byte *out) +{ +#if ODOH_HAVE_HPKE_CONTEXT_API + return wc_HpkeContextSealBase(hpke, ctx, aad, aad_sz, pt, pt_sz, out); +#else + (void)hpke; (void)ctx; (void)aad; (void)aad_sz; (void)pt; (void)pt_sz; (void)out; + return -1; +#endif +} + +static int odoh_hpke_init_open_context(Hpke *hpke, HpkeBaseContext *ctx, + void *receiver, const byte *enc, word16 enc_sz, byte *info, word32 info_sz) +{ +#if ODOH_HAVE_HPKE_CONTEXT_API + return wc_HpkeInitOpenContext(hpke, ctx, receiver, enc, enc_sz, info, info_sz); +#else + (void)hpke; (void)ctx; (void)receiver; (void)enc; (void)enc_sz; (void)info; (void)info_sz; + return -1; +#endif +} + +static int odoh_hpke_context_open_base(Hpke *hpke, HpkeBaseContext *ctx, + byte *aad, word32 aad_sz, byte *ct, word32 ct_sz, byte *out) +{ +#if ODOH_HAVE_HPKE_CONTEXT_API + return wc_HpkeContextOpenBase(hpke, ctx, aad, aad_sz, ct, ct_sz, out); +#else + (void)hpke; (void)ctx; (void)aad; (void)aad_sz; (void)ct; (void)ct_sz; (void)out; + return -1; +#endif +} + +static uint16_t be16(const uint8_t *p) +{ + return (uint16_t)((p[0] << 8) | p[1]); +} + +static void put16(uint8_t *p, uint16_t v) +{ + p[0] = (uint8_t)(v >> 8); + p[1] = (uint8_t)(v & 0xff); +} + +static int kdf_to_hash(uint16_t kdf_id) +{ + switch (kdf_id) { + case HKDF_SHA256: return WC_SHA256; + case HKDF_SHA384: return WC_SHA384; + case HKDF_SHA512: return WC_SHA512; + default: return -1; + } +} + +static uint16_t kdf_nh(uint16_t kdf_id) +{ + switch (kdf_id) { + case HKDF_SHA256: return 32; + case HKDF_SHA384: return 48; + case HKDF_SHA512: return 64; + default: return 0; + } +} + +static int hpke_init_for_cfg(const odoh_config *cfg, Hpke *hpke) +{ + return wc_HpkeInit(hpke, cfg->kem_id, cfg->kdf_id, cfg->aead_id, NULL); +} + +static int odoh_compute_key_id(const odoh_config *cfg, uint8_t *out, uint16_t *out_len) +{ + uint8_t content[2 + 2 + 2 + 2 + ODOH_MAX_PUBLIC_KEY]; + uint8_t prk[64]; + uint16_t nh; + int htype; + int ret; + uint16_t content_len; + + if (!cfg || !out || !out_len) + return -1; + + htype = kdf_to_hash(cfg->kdf_id); + nh = kdf_nh(cfg->kdf_id); + if (htype < 0 || nh == 0) + return -1; + + put16(content + 0, cfg->kem_id); + put16(content + 2, cfg->kdf_id); + put16(content + 4, cfg->aead_id); + put16(content + 6, cfg->public_key_len); + memcpy(content + 8, cfg->public_key, cfg->public_key_len); + content_len = (uint16_t)(8 + cfg->public_key_len); + + ret = wc_HKDF_Extract(htype, NULL, 0, content, content_len, prk); + if (ret != 0) + return -1; + + ret = wc_HKDF_Expand(htype, prk, nh, + (const uint8_t *)ODOH_LABEL_KEYID, (word32)strlen(ODOH_LABEL_KEYID), out, nh); + if (ret != 0) + return -1; + + *out_len = nh; + return 0; +} + +static int parse_configs_blob(const uint8_t *buf, size_t sz, odoh_config *cfg) +{ + size_t off = 0; + uint16_t total_len; + + if (sz < 2) + return -1; + + total_len = be16(buf); + off = 2; + if (total_len + 2 > sz) + return -1; + + while ((off + 4) <= (size_t)(2 + total_len)) { + uint16_t version = be16(buf + off); + uint16_t clen = be16(buf + off + 2); + size_t cstart = off + 4; + + if ((cstart + clen) > (size_t)(2 + total_len)) + return -1; + + if (version == ODOH_VERSION && clen >= 8) { + cfg->version = version; + cfg->kem_id = be16(buf + cstart + 0); + cfg->kdf_id = be16(buf + cstart + 2); + cfg->aead_id = be16(buf + cstart + 4); + cfg->public_key_len = be16(buf + cstart + 6); + + if ((size_t)(8 + cfg->public_key_len) > clen) + return -1; + if (cfg->public_key_len > ODOH_MAX_PUBLIC_KEY) + return -1; + + memcpy(cfg->public_key, buf + cstart + 8, cfg->public_key_len); + + if (odoh_compute_key_id(cfg, cfg->key_id, &cfg->key_id_len) != 0) + return -1; + + return 0; + } + + off = cstart + clen; + } + + return -1; +} + +int odoh_config_load_file(const char *path, odoh_config *cfg) +{ + FILE *fp; + uint8_t buf[ODOH_MAX_CONFIG]; + size_t n; + + if (!path || !cfg) + return -1; + + memset(cfg, 0, sizeof(*cfg)); + + fp = fopen(path, "rb"); + if (!fp) + return -1; + + n = fread(buf, 1, sizeof(buf), fp); + fclose(fp); + + if (n < 2) + return -1; + + return parse_configs_blob(buf, n, cfg); +} + +int odoh_target_load_files(const char *cfg_path, const char *secret_path, + odoh_target_ctx *target) +{ + FILE *fp; + uint8_t priv[32]; + + if (!target || !cfg_path || !secret_path) + return -1; + + memset(target, 0, sizeof(*target)); + + if (odoh_config_load_file(cfg_path, &target->cfg) != 0) + return -1; + + if (target->cfg.kem_id != DHKEM_X25519_HKDF_SHA256) + return -1; + + fp = fopen(secret_path, "rb"); + if (!fp) + return -1; + if (fread(priv, 1, sizeof(priv), fp) != sizeof(priv)) { + fclose(fp); + return -1; + } + fclose(fp); + + if (wc_curve25519_init(&target->priv) != 0) + return -1; + + if (wc_curve25519_import_private_raw(priv, sizeof(priv), + target->cfg.public_key, target->cfg.public_key_len, + &target->priv) != 0) { + wc_curve25519_free(&target->priv); + return -1; + } + + target->loaded = 1; + return 0; +} + +void odoh_target_free(odoh_target_ctx *target) +{ + if (!target) + return; + if (target->loaded) + wc_curve25519_free(&target->priv); + memset(target, 0, sizeof(*target)); +} + +int odoh_parse_message(const uint8_t *in, size_t in_len, odoh_message_view *msg) +{ + size_t off = 0; + uint16_t klen; + uint16_t elen; + + if (!in || !msg || in_len < 5) + return -1; + + memset(msg, 0, sizeof(*msg)); + + msg->message_type = in[off++]; + klen = be16(in + off); + off += 2; + if ((off + klen + 2) > in_len) + return -1; + + msg->key_id = in + off; + msg->key_id_len = klen; + off += klen; + + elen = be16(in + off); + off += 2; + if (elen == 0 || (off + elen) > in_len) + return -1; + + msg->encrypted = in + off; + msg->encrypted_len = elen; + return 0; +} + +static int build_plaintext(const uint8_t *dns, uint16_t dns_len, + uint8_t *out, uint16_t *out_len) +{ + if (!dns || !out || !out_len) + return -1; + if (dns_len == 0 || dns_len > (ODOH_MAX_MESSAGE - 4)) + return -1; + + put16(out + 0, dns_len); + memcpy(out + 2, dns, dns_len); + put16(out + 2 + dns_len, 0); + *out_len = (uint16_t)(dns_len + 4); + return 0; +} + +static int parse_plaintext_dns(const uint8_t *plain, uint16_t plain_len, + uint8_t *dns_out, uint16_t *dns_out_len) +{ + uint16_t dns_len; + uint16_t pad_len; + size_t i; + + if (!plain || !dns_out || !dns_out_len || plain_len < 4) + return -1; + + dns_len = be16(plain + 0); + if ((size_t)(2 + dns_len + 2) > plain_len) + return -1; + + pad_len = be16(plain + 2 + dns_len); + if ((size_t)(2 + dns_len + 2 + pad_len) > plain_len) + return -1; + + for (i = 0; i < pad_len; i++) { + if (plain[2 + dns_len + 2 + i] != 0) + return -1; + } + + memcpy(dns_out, plain + 2, dns_len); + *dns_out_len = dns_len; + return 0; +} + +static int build_query_aad(const uint8_t *key_id, uint16_t key_id_len, + uint8_t *aad, uint16_t *aad_len) +{ + if (!aad || !aad_len || !key_id) + return -1; + aad[0] = ODOH_MSG_QUERY; + put16(aad + 1, key_id_len); + memcpy(aad + 3, key_id, key_id_len); + *aad_len = (uint16_t)(3 + key_id_len); + return 0; +} + +static int build_response_aad(const uint8_t *resp_nonce, uint16_t nonce_len, + uint8_t *aad, uint16_t *aad_len) +{ + aad[0] = ODOH_MSG_RESPONSE; + put16(aad + 1, nonce_len); + memcpy(aad + 3, resp_nonce, nonce_len); + *aad_len = (uint16_t)(3 + nonce_len); + return 0; +} + +static int derive_response_secret(const Hpke *hpke, const HpkeBaseContext *ctx, + uint8_t *out, uint16_t out_len) +{ + int htype = kdf_to_hash((uint16_t)hpke->kdf); + if (htype < 0) + return -1; + return wc_HKDF_Expand(htype, ctx->exporter_secret, hpke->Nsecret, + (const uint8_t *)ODOH_LABEL_RESPONSE, (word32)strlen(ODOH_LABEL_RESPONSE), + out, out_len); +} + +static int derive_response_key_nonce(const Hpke *hpke, const HpkeBaseContext *ctx, + const uint8_t *q_plain, uint16_t q_plain_len, + const uint8_t *resp_nonce, uint16_t resp_nonce_len, + uint8_t *key_out, uint16_t key_len, + uint8_t *nonce_out, uint16_t nonce_len) +{ + int htype = kdf_to_hash((uint16_t)hpke->kdf); + uint8_t secret[64]; + uint8_t prk[64]; + uint8_t salt[ODOH_MAX_MESSAGE + 2 + 64]; + uint16_t salt_len; + + if (htype < 0) + return -1; + + if ((size_t)q_plain_len + 2 + resp_nonce_len > sizeof(salt)) + return -1; + + if (derive_response_secret(hpke, ctx, secret, key_len) != 0) + return -1; + + memcpy(salt, q_plain, q_plain_len); + put16(salt + q_plain_len, resp_nonce_len); + memcpy(salt + q_plain_len + 2, resp_nonce, resp_nonce_len); + salt_len = (uint16_t)(q_plain_len + 2 + resp_nonce_len); + + if (wc_HKDF_Extract(htype, salt, salt_len, secret, key_len, prk) != 0) + return -1; + + if (wc_HKDF_Expand(htype, prk, kdf_nh((uint16_t)hpke->kdf), + (const uint8_t *)ODOH_LABEL_KEY, (word32)strlen(ODOH_LABEL_KEY), + key_out, key_len) != 0) + return -1; + + if (wc_HKDF_Expand(htype, prk, kdf_nh((uint16_t)hpke->kdf), + (const uint8_t *)ODOH_LABEL_NONCE, (word32)strlen(ODOH_LABEL_NONCE), + nonce_out, nonce_len) != 0) + return -1; + + return 0; +} + +int odoh_client_encrypt_query(const odoh_config *cfg, + const uint8_t *dns_msg, uint16_t dns_len, + uint8_t *out, uint16_t *out_len, + odoh_client_ctx *client_ctx) +{ +#if !ODOH_HAVE_HPKE_CONTEXT_API + (void)cfg; (void)dns_msg; (void)dns_len; (void)out; (void)out_len; (void)client_ctx; + return -1; +#else + uint8_t plain[ODOH_MAX_MESSAGE]; + uint16_t plain_len; + uint8_t aad[3 + ODOH_MAX_KEY_ID]; + uint16_t aad_len; + uint8_t enc[ODOH_MAX_PUBLIC_KEY]; + word16 enc_len = sizeof(enc); + uint8_t ct[ODOH_MAX_MESSAGE]; + size_t off = 0; + int ct_len; + void *receiver = NULL; + void *eph = NULL; + WC_RNG rng; + + if (!cfg || !dns_msg || !out || !out_len || !client_ctx) + return -1; + + memset(client_ctx, 0, sizeof(*client_ctx)); + + if (build_plaintext(dns_msg, dns_len, plain, &plain_len) != 0) + return -1; + + if (hpke_init_for_cfg(cfg, &client_ctx->hpke) != 0) + return -1; + + if (wc_HpkeDeserializePublicKey(&client_ctx->hpke, &receiver, + cfg->public_key, cfg->public_key_len) != 0) + return -1; + + if (wc_InitRng(&rng) != 0) + return -1; + + if (wc_HpkeGenerateKeyPair(&client_ctx->hpke, &eph, &rng) != 0) { + wc_FreeRng(&rng); + return -1; + } + + if (odoh_hpke_init_seal_context(&client_ctx->hpke, &client_ctx->hpke_ctx, + eph, receiver, (byte *)ODOH_INFO_QUERY, (word32)strlen(ODOH_INFO_QUERY)) != 0) { + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, eph, NULL); + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, receiver, NULL); + wc_FreeRng(&rng); + return -1; + } + + if (wc_HpkeSerializePublicKey(&client_ctx->hpke, eph, enc, &enc_len) != 0) { + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, eph, NULL); + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, receiver, NULL); + wc_FreeRng(&rng); + return -1; + } + + if (build_query_aad(cfg->key_id, cfg->key_id_len, aad, &aad_len) != 0) { + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, eph, NULL); + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, receiver, NULL); + wc_FreeRng(&rng); + return -1; + } + + ct_len = plain_len + client_ctx->hpke.Nt; + if (odoh_hpke_context_seal_base(&client_ctx->hpke, &client_ctx->hpke_ctx, + aad, aad_len, plain, plain_len, ct) != 0) { + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, eph, NULL); + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, receiver, NULL); + wc_FreeRng(&rng); + return -1; + } + + if ((size_t)(1 + 2 + cfg->key_id_len + 2 + enc_len + ct_len) > ODOH_MAX_MESSAGE) { + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, eph, NULL); + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, receiver, NULL); + wc_FreeRng(&rng); + return -1; + } + + out[off++] = ODOH_MSG_QUERY; + put16(out + off, cfg->key_id_len); + off += 2; + memcpy(out + off, cfg->key_id, cfg->key_id_len); + off += cfg->key_id_len; + put16(out + off, (uint16_t)(enc_len + ct_len)); + off += 2; + memcpy(out + off, enc, enc_len); + off += enc_len; + memcpy(out + off, ct, ct_len); + off += ct_len; + + memcpy(&client_ctx->cfg, cfg, sizeof(*cfg)); + memcpy(client_ctx->q_plain, plain, plain_len); + client_ctx->q_plain_len = plain_len; + client_ctx->valid = 1; + + *out_len = (uint16_t)off; + + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, eph, NULL); + wc_HpkeFreeKey(&client_ctx->hpke, cfg->kem_id, receiver, NULL); + wc_FreeRng(&rng); + return 0; +#endif +} + +int odoh_target_decrypt_query(odoh_target_ctx *target, + const uint8_t *in, uint16_t in_len, + uint8_t *dns_out, uint16_t *dns_out_len, + odoh_req_ctx *req_ctx) +{ +#if !ODOH_HAVE_HPKE_CONTEXT_API + (void)target; (void)in; (void)in_len; (void)dns_out; (void)dns_out_len; (void)req_ctx; + return -1; +#else + odoh_message_view msg; + uint8_t aad[3 + ODOH_MAX_KEY_ID]; + uint16_t aad_len; + uint16_t enc_len; + const uint8_t *enc; + const uint8_t *ct; + uint16_t ct_len; + uint8_t plain[ODOH_MAX_MESSAGE]; + + if (!target || !target->loaded || !in || !dns_out || !dns_out_len || !req_ctx) + return -1; + + memset(req_ctx, 0, sizeof(*req_ctx)); + memcpy(&req_ctx->cfg, &target->cfg, sizeof(target->cfg)); + if (hpke_init_for_cfg(&req_ctx->cfg, &req_ctx->hpke) != 0) + return -1; + + if (odoh_parse_message(in, in_len, &msg) != 0) + return -1; + + if (msg.message_type != ODOH_MSG_QUERY) + return -1; + + if (msg.key_id_len != target->cfg.key_id_len || + memcmp(msg.key_id, target->cfg.key_id, msg.key_id_len) != 0) + return -1; + + enc_len = (uint16_t)req_ctx->hpke.Npk; + if (msg.encrypted_len <= enc_len) + return -1; + + enc = msg.encrypted; + ct = msg.encrypted + enc_len; + ct_len = (uint16_t)(msg.encrypted_len - enc_len); + + if (odoh_hpke_init_open_context(&req_ctx->hpke, &req_ctx->hpke_ctx, + &target->priv, enc, enc_len, + (byte *)ODOH_INFO_QUERY, (word32)strlen(ODOH_INFO_QUERY)) != 0) + return -1; + + if (build_query_aad(target->cfg.key_id, target->cfg.key_id_len, aad, &aad_len) != 0) + return -1; + + if (odoh_hpke_context_open_base(&req_ctx->hpke, &req_ctx->hpke_ctx, + aad, aad_len, (byte *)ct, ct_len, plain) != 0) + return -1; + + if (parse_plaintext_dns(plain, (uint16_t)(ct_len - req_ctx->hpke.Nt), dns_out, dns_out_len) != 0) + return -1; + + memcpy(req_ctx->q_plain, plain, (size_t)(ct_len - req_ctx->hpke.Nt)); + req_ctx->q_plain_len = (uint16_t)(ct_len - req_ctx->hpke.Nt); + req_ctx->valid = 1; + + return 0; +#endif +} + +int odoh_target_encrypt_response(const odoh_req_ctx *req_ctx, + const uint8_t *dns_msg, uint16_t dns_len, + uint8_t *out, uint16_t *out_len) +{ +#if !ODOH_HAVE_HPKE_CONTEXT_API + (void)req_ctx; (void)dns_msg; (void)dns_len; (void)out; (void)out_len; + return -1; +#else + uint8_t plain[ODOH_MAX_MESSAGE]; + uint16_t plain_len; + uint8_t resp_nonce[64]; + uint16_t resp_nonce_len; + uint8_t aad[3 + 64]; + uint16_t aad_len; + uint8_t aead_key[32]; + uint8_t aead_nonce[16]; + uint8_t ct[ODOH_MAX_MESSAGE]; + Aes aes; + int ret; + WC_RNG rng; + size_t off = 0; + + if (!req_ctx || !req_ctx->valid || !dns_msg || !out || !out_len) + return -1; + + if (build_plaintext(dns_msg, dns_len, plain, &plain_len) != 0) + return -1; + + resp_nonce_len = (uint16_t)((req_ctx->hpke.Nn > req_ctx->hpke.Nk) ? req_ctx->hpke.Nn : req_ctx->hpke.Nk); + if (resp_nonce_len > sizeof(resp_nonce)) + return -1; + + if (wc_InitRng(&rng) != 0) + return -1; + if (wc_RNG_GenerateBlock(&rng, resp_nonce, resp_nonce_len) != 0) { + wc_FreeRng(&rng); + return -1; + } + wc_FreeRng(&rng); + + if (derive_response_key_nonce(&req_ctx->hpke, &req_ctx->hpke_ctx, + req_ctx->q_plain, req_ctx->q_plain_len, + resp_nonce, resp_nonce_len, + aead_key, req_ctx->hpke.Nk, + aead_nonce, req_ctx->hpke.Nn) != 0) + return -1; + + if (build_response_aad(resp_nonce, resp_nonce_len, aad, &aad_len) != 0) + return -1; + + if (wc_AesInit(&aes, NULL, INVALID_DEVID) != 0) + return -1; + + ret = wc_AesGcmSetKey(&aes, aead_key, req_ctx->hpke.Nk); + if (ret == 0) { + ret = wc_AesGcmEncrypt(&aes, ct, plain, plain_len, + aead_nonce, req_ctx->hpke.Nn, + ct + plain_len, req_ctx->hpke.Nt, + aad, aad_len); + } + wc_AesFree(&aes); + if (ret != 0) + return -1; + + if ((size_t)(1 + 2 + resp_nonce_len + 2 + plain_len + req_ctx->hpke.Nt) > ODOH_MAX_MESSAGE) + return -1; + + out[off++] = ODOH_MSG_RESPONSE; + put16(out + off, resp_nonce_len); + off += 2; + memcpy(out + off, resp_nonce, resp_nonce_len); + off += resp_nonce_len; + put16(out + off, (uint16_t)(plain_len + req_ctx->hpke.Nt)); + off += 2; + memcpy(out + off, ct, plain_len + req_ctx->hpke.Nt); + off += plain_len + req_ctx->hpke.Nt; + + *out_len = (uint16_t)off; + return 0; +#endif +} + +int odoh_client_decrypt_response(odoh_client_ctx *client_ctx, + const uint8_t *in, uint16_t in_len, + uint8_t *dns_out, uint16_t *dns_out_len) +{ +#if !ODOH_HAVE_HPKE_CONTEXT_API + (void)client_ctx; (void)in; (void)in_len; (void)dns_out; (void)dns_out_len; + return -1; +#else + odoh_message_view msg; + uint8_t aad[3 + 64]; + uint16_t aad_len; + uint8_t aead_key[32]; + uint8_t aead_nonce[16]; + uint8_t plain[ODOH_MAX_MESSAGE]; + Aes aes; + int ret; + + if (!client_ctx || !client_ctx->valid || !in || !dns_out || !dns_out_len) + return -1; + + if (odoh_parse_message(in, in_len, &msg) != 0) + return -1; + + if (msg.message_type != ODOH_MSG_RESPONSE) + return -1; + + if (msg.key_id_len == 0 || msg.key_id_len > 64) + return -1; + + if (msg.encrypted_len <= client_ctx->hpke.Nt) + return -1; + + if (derive_response_key_nonce(&client_ctx->hpke, &client_ctx->hpke_ctx, + client_ctx->q_plain, client_ctx->q_plain_len, + msg.key_id, msg.key_id_len, + aead_key, client_ctx->hpke.Nk, + aead_nonce, client_ctx->hpke.Nn) != 0) + return -1; + + if (build_response_aad(msg.key_id, msg.key_id_len, aad, &aad_len) != 0) + return -1; + + if (wc_AesInit(&aes, NULL, INVALID_DEVID) != 0) + return -1; + + ret = wc_AesGcmSetKey(&aes, aead_key, client_ctx->hpke.Nk); + if (ret == 0) { + uint16_t pt_len = (uint16_t)(msg.encrypted_len - client_ctx->hpke.Nt); + ret = wc_AesGcmDecrypt(&aes, plain, + msg.encrypted, pt_len, + aead_nonce, client_ctx->hpke.Nn, + msg.encrypted + pt_len, client_ctx->hpke.Nt, + aad, aad_len); + if (ret == 0) + ret = parse_plaintext_dns(plain, pt_len, dns_out, dns_out_len); + } + wc_AesFree(&aes); + + return ret == 0 ? 0 : -1; +#endif +} diff --git a/src/odoh.h b/src/odoh.h new file mode 100644 index 0000000..fc6ca49 --- /dev/null +++ b/src/odoh.h @@ -0,0 +1,78 @@ +#ifndef DOHD_ODOH_H +#define DOHD_ODOH_H + +#include +#include +#include +#include +#include + +#define ODOH_MSG_QUERY 0x01 +#define ODOH_MSG_RESPONSE 0x02 +#define ODOH_MAX_CONFIG 2048 +#define ODOH_MAX_MESSAGE 65535 +#define ODOH_MAX_KEY_ID 64 +#define ODOH_MAX_PUBLIC_KEY 133 + +typedef struct { + uint16_t version; + uint16_t kem_id; + uint16_t kdf_id; + uint16_t aead_id; + uint8_t public_key[ODOH_MAX_PUBLIC_KEY]; + uint16_t public_key_len; + uint8_t key_id[ODOH_MAX_KEY_ID]; + uint16_t key_id_len; +} odoh_config; + +typedef struct { + uint8_t message_type; + const uint8_t *key_id; + uint16_t key_id_len; + const uint8_t *encrypted; + uint16_t encrypted_len; +} odoh_message_view; + +typedef struct { + Hpke hpke; + HpkeBaseContext hpke_ctx; + odoh_config cfg; + uint8_t q_plain[ODOH_MAX_MESSAGE]; + uint16_t q_plain_len; + int valid; +} odoh_client_ctx; + +typedef odoh_client_ctx odoh_req_ctx; + +typedef struct { + odoh_config cfg; + curve25519_key priv; + int loaded; +} odoh_target_ctx; + +int odoh_config_load_file(const char *path, odoh_config *cfg); +int odoh_target_load_files(const char *cfg_path, const char *secret_path, + odoh_target_ctx *target); +void odoh_target_free(odoh_target_ctx *target); + +int odoh_parse_message(const uint8_t *in, size_t in_len, odoh_message_view *msg); + +int odoh_client_encrypt_query(const odoh_config *cfg, + const uint8_t *dns_msg, uint16_t dns_len, + uint8_t *out, uint16_t *out_len, + odoh_client_ctx *client_ctx); + +int odoh_client_decrypt_response(odoh_client_ctx *client_ctx, + const uint8_t *in, uint16_t in_len, + uint8_t *dns_out, uint16_t *dns_out_len); + +int odoh_target_decrypt_query(odoh_target_ctx *target, + const uint8_t *in, uint16_t in_len, + uint8_t *dns_out, uint16_t *dns_out_len, + odoh_req_ctx *req_ctx); + +int odoh_target_encrypt_response(const odoh_req_ctx *req_ctx, + const uint8_t *dns_msg, uint16_t dns_len, + uint8_t *out, uint16_t *out_len); + +#endif diff --git a/src/proxy_auth.c b/src/proxy_auth.c new file mode 100644 index 0000000..f46483a --- /dev/null +++ b/src/proxy_auth.c @@ -0,0 +1,153 @@ +#include "proxy_auth.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +static int pkey_to_hash(WOLFSSL_EVP_PKEY *pkey, uint8_t out[PROXY_AUTH_HASH_LEN]) +{ + unsigned char *der = NULL; + int der_len; + + if (!pkey) + return -1; + + der_len = wolfSSL_i2d_PUBKEY(pkey, &der); + if (der_len <= 0 || !der) + return -1; + + if (wc_Sha256Hash(der, (word32)der_len, out) != 0) { + free(der); + return -1; + } + + free(der); + return 0; +} + +void proxy_auth_free(proxy_auth_set *set) +{ + if (!set) + return; + free(set->hashes); + set->hashes = NULL; + set->count = 0; +} + +int proxy_auth_load_dir(const char *dirpath, proxy_auth_set *set) +{ + DIR *d; + struct dirent *de; + proxy_auth_set tmp = {}; + + if (!dirpath || !set) + return -1; + + d = opendir(dirpath); + if (!d) + return -1; + + while ((de = readdir(d)) != NULL) { + char path[PATH_MAX]; + struct stat st; + WOLFSSL_BIO *bio = NULL; + WOLFSSL_EVP_PKEY *pkey = NULL; + uint8_t hash[PROXY_AUTH_HASH_LEN]; + uint8_t *nptr; + size_t i; + int dup = 0; + + if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) + continue; + + snprintf(path, sizeof(path), "%s/%s", dirpath, de->d_name); + if (stat(path, &st) != 0 || !S_ISREG(st.st_mode)) + continue; + + bio = wolfSSL_BIO_new_file(path, "rb"); + if (!bio) + continue; + + pkey = wolfSSL_PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL); + wolfSSL_BIO_free(bio); + if (!pkey) + continue; + + if (pkey_to_hash(pkey, hash) != 0) { + wolfSSL_EVP_PKEY_free(pkey); + continue; + } + wolfSSL_EVP_PKEY_free(pkey); + + for (i = 0; i < tmp.count; i++) { + if (memcmp(tmp.hashes + (i * PROXY_AUTH_HASH_LEN), hash, PROXY_AUTH_HASH_LEN) == 0) { + dup = 1; + break; + } + } + if (dup) + continue; + + nptr = realloc(tmp.hashes, (tmp.count + 1) * PROXY_AUTH_HASH_LEN); + if (!nptr) { + proxy_auth_free(&tmp); + closedir(d); + return -1; + } + + tmp.hashes = nptr; + memcpy(tmp.hashes + (tmp.count * PROXY_AUTH_HASH_LEN), hash, PROXY_AUTH_HASH_LEN); + tmp.count++; + } + + closedir(d); + + proxy_auth_free(set); + *set = tmp; + return 0; +} + +int proxy_auth_peer_allowed(WOLFSSL *ssl, const proxy_auth_set *set) +{ + WOLFSSL_X509 *cert = NULL; + WOLFSSL_EVP_PKEY *pkey = NULL; + uint8_t hash[PROXY_AUTH_HASH_LEN]; + size_t i; + int allowed = 0; + + if (!ssl || !set || set->count == 0) + return 0; + + cert = wolfSSL_get_peer_certificate(ssl); + if (!cert) + return 0; + + pkey = wolfSSL_X509_get_pubkey(cert); + wolfSSL_X509_free(cert); + if (!pkey) + return 0; + + if (pkey_to_hash(pkey, hash) != 0) { + wolfSSL_EVP_PKEY_free(pkey); + return 0; + } + + wolfSSL_EVP_PKEY_free(pkey); + + for (i = 0; i < set->count; i++) { + if (memcmp(set->hashes + (i * PROXY_AUTH_HASH_LEN), hash, PROXY_AUTH_HASH_LEN) == 0) { + allowed = 1; + break; + } + } + + return allowed; +} diff --git a/src/proxy_auth.h b/src/proxy_auth.h new file mode 100644 index 0000000..dc4606b --- /dev/null +++ b/src/proxy_auth.h @@ -0,0 +1,20 @@ +#ifndef DOHD_PROXY_AUTH_H +#define DOHD_PROXY_AUTH_H + +#include +#include +#include +#include + +#define PROXY_AUTH_HASH_LEN 32 + +typedef struct { + uint8_t *hashes; + size_t count; +} proxy_auth_set; + +int proxy_auth_load_dir(const char *dirpath, proxy_auth_set *set); +void proxy_auth_free(proxy_auth_set *set); +int proxy_auth_peer_allowed(WOLFSSL *ssl, const proxy_auth_set *set); + +#endif diff --git a/test/Makefile b/test/Makefile index d3a17f6..efe3a2c 100644 --- a/test/Makefile +++ b/test/Makefile @@ -8,7 +8,7 @@ CFLAGS := -I../src -O0 -ggdb -Wall -Wextra -Wno-sign-compare \ CFLAGS += $(if $(shell ldd /bin/ls | grep 'musl' | head -1 | cut -d ' ' -f1), -D_MUSL_,) # Default target: build all unit tests -all: dohd_url64_test dohd_url64_extended_test dohd_heap_test dohd_dns_parser_test dohd_mempool_test +all: dohd_url64_test dohd_url64_extended_test dohd_heap_test dohd_dns_parser_test dohd_mempool_test dohd_h2_session_test # Run all unit tests check: all @@ -29,6 +29,9 @@ check: all @echo "--- Memory Pool Tests ---" ./dohd_mempool_test @echo "" + @echo "--- HTTP/2 Session Tests ---" + ./dohd_h2_session_test + @echo "" @echo "=== All Unit Tests Passed ===" # Original url64 test @@ -51,6 +54,10 @@ dohd_dns_parser_test: test_dns_parser.o dohd_mempool_test: mempool.o test_mempool.o ${CC} -o dohd_mempool_test mempool.o test_mempool.o +# HTTP/2 session helper test +dohd_h2_session_test: h2_session.o test_h2_session.o + ${CC} -o dohd_h2_session_test h2_session.o test_h2_session.o + # Object files url64.o: ../src/url64.c ${CC} ${CFLAGS} -c -o url64.o ../src/url64.c @@ -73,9 +80,15 @@ test_dns_parser.o: test_dns_parser.c mempool.o: ../src/mempool.c ../src/mempool.h ${CC} ${CFLAGS} -c -o mempool.o ../src/mempool.c +h2_session.o: ../src/h2_session.c ../src/h2_session.h + ${CC} ${CFLAGS} -c -o h2_session.o ../src/h2_session.c + test_mempool.o: test_mempool.c ../src/mempool.h ${CC} ${CFLAGS} -c -o test_mempool.o test_mempool.c +test_h2_session.o: test_h2_session.c ../src/h2_session.h + ${CC} ${CFLAGS} -c -o test_h2_session.o test_h2_session.c + # Integration tests (require running dohd) integration: gen-certs @echo "=== Running Integration Tests ===" @@ -126,7 +139,7 @@ gen-certs: clean: rm -f *.o - rm -f dohd_url64_test dohd_url64_extended_test dohd_heap_test dohd_dns_parser_test dohd_mempool_test + rm -f dohd_url64_test dohd_url64_extended_test dohd_heap_test dohd_dns_parser_test dohd_mempool_test dohd_h2_session_test rm -f test.crt test.key valgrind.log .PHONY: all check integration valgrind gen-certs clean diff --git a/test/stress_test.sh b/test/stress_test.sh index f7e137f..6002ac3 100755 --- a/test/stress_test.sh +++ b/test/stress_test.sh @@ -115,6 +115,8 @@ worker() { result=$(curl -s -k --http2 -4 \ --connect-timeout 2 \ --max-time 5 \ + -H "Accept: application/dns-message" \ + -H "Content-Type: application/dns-message" \ -o /dev/null \ -w "%{http_code}" \ "https://127.0.0.1:$PORT/?dns=$dns_q" 2>&1) @@ -177,7 +179,7 @@ fi # Verify responding echo "Verifying dohd is responsive..." for i in {1..5}; do - if timeout 5 curl -s -k --http2 -4 "https://127.0.0.1:$PORT/?dns=${DNS_QUERIES[0]}" -o /dev/null 2>&1; then + if timeout 5 curl -s -k --http2 -4 -H "Accept: application/dns-message" -H "Content-Type: application/dns-message" "https://127.0.0.1:$PORT/?dns=${DNS_QUERIES[0]}" -o /dev/null 2>&1; then echo "dohd is responding" break fi @@ -218,7 +220,7 @@ while [ $(date +%s) -lt $END_TIME ]; do fi # Health check - if ! timeout 5 curl -s -k --http2 -4 "https://127.0.0.1:$PORT/?dns=${DNS_QUERIES[0]}" -o /dev/null 2>&1; then + if ! timeout 5 curl -s -k --http2 -4 -H "Accept: application/dns-message" -H "Content-Type: application/dns-message" "https://127.0.0.1:$PORT/?dns=${DNS_QUERIES[0]}" -o /dev/null 2>&1; then echo "WARNING: Health check failed at $(date +%H:%M:%S)" fi diff --git a/test/test_h2_session.c b/test/test_h2_session.c new file mode 100644 index 0000000..bcb6e9c --- /dev/null +++ b/test/test_h2_session.c @@ -0,0 +1,49 @@ +/* dohd test unit for HTTP/2 session helpers */ + +#include +#include "../src/h2_session.h" + +static int tests_run = 0; +static int tests_passed = 0; + +#define TEST_ASSERT(cond, msg) do { \ + tests_run++; \ + if (!(cond)) { \ + fprintf(stderr, "FAIL: %s\n", msg); \ + return 0; \ + } \ + tests_passed++; \ + fprintf(stderr, "PASS: %s\n", msg); \ +} while (0) + +static int test_stream_close_ignores_detached_stream(void) +{ + TEST_ASSERT(dohd_h2_stream_close_action(0, 0) == + DOHD_H2_STREAM_CLOSE_IGNORE, + "stream close ignores missing request"); + TEST_ASSERT(dohd_h2_stream_close_action(1, 0) == + DOHD_H2_STREAM_CLOSE_IGNORE, + "stream close ignores owner mismatch"); + return 1; +} + +static int test_stream_close_destroys_owned_request(void) +{ + TEST_ASSERT(dohd_h2_stream_close_action(1, 1) == + DOHD_H2_STREAM_CLOSE_DESTROY, + "stream close destroys matching request"); + return 1; +} + +int main(void) +{ + int ok = 1; + + fprintf(stderr, "=== HTTP/2 Session Helper Tests ===\n"); + + ok &= test_stream_close_ignores_detached_stream(); + ok &= test_stream_close_destroys_owned_request(); + + fprintf(stderr, "\n%d/%d tests passed\n", tests_passed, tests_run); + return ok ? 0 : 1; +} diff --git a/tools/Makefile b/tools/Makefile new file mode 100644 index 0000000..3c45e95 --- /dev/null +++ b/tools/Makefile @@ -0,0 +1,22 @@ +CC=gcc + +CFLAGS := -Wall -Wextra -Wno-sign-compare \ + -DVERSION=\"${VERSION}\" -fPIE + +LDFLAGS:=-lwolfssl -lm + +all: CFLAGS+= -O3 +all: odoh-keygen + +debug: CFLAGS+= -ggdb -O0 +debug: odoh-keygen + +asan: CFLAGS+= -fsanitize=address +asan: LDFLAGS+= -fsanitize=address +asan: odoh-keygen + +odoh-keygen: odoh-keygen.o + $(CC) -o $@ $^ $(LDFLAGS) + +clean: + rm -f *.o odoh-keygen diff --git a/tools/odoh-keygen.c b/tools/odoh-keygen.c new file mode 100644 index 0000000..0c248ae --- /dev/null +++ b/tools/odoh-keygen.c @@ -0,0 +1,189 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define ODOH_VERSION 0x0001 + +static void put16(uint8_t *p, uint16_t v) +{ + p[0] = (uint8_t)(v >> 8); + p[1] = (uint8_t)(v & 0xff); +} + +static int write_file(const char *path, const uint8_t *buf, size_t len, mode_t mode) +{ + int fd; + ssize_t w; + size_t off = 0; + + fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, mode); + if (fd < 0) { + fprintf(stderr, "Cannot open %s: %s\n", path, strerror(errno)); + return -1; + } + + while (off < len) { + w = write(fd, buf + off, len - off); + if (w <= 0) { + close(fd); + fprintf(stderr, "Cannot write %s: %s\n", path, strerror(errno)); + return -1; + } + off += (size_t)w; + } + + if (close(fd) != 0) { + fprintf(stderr, "Cannot close %s: %s\n", path, strerror(errno)); + return -1; + } + + return 0; +} + +static int write_odoh_config(const char *path, const uint8_t *pub, size_t pub_len) +{ + uint8_t header[2 + 2 + 2 + 2 + 2 + 2 + 2]; + uint16_t cfg_contents_len; + uint16_t cfg_record_len; + uint16_t total_len; + + if (pub_len > 65535u) + return -1; + + cfg_contents_len = (uint16_t)(8u + pub_len); + cfg_record_len = (uint16_t)(4u + cfg_contents_len); + total_len = cfg_record_len; + + put16(header + 0, total_len); + put16(header + 2, ODOH_VERSION); + put16(header + 4, cfg_contents_len); + put16(header + 6, DHKEM_X25519_HKDF_SHA256); + put16(header + 8, HKDF_SHA256); + put16(header + 10, HPKE_AES_128_GCM); + put16(header + 12, (uint16_t)pub_len); + + if (write_file(path, header, sizeof(header), 0644) != 0) + return -1; + + { + int fd = open(path, O_WRONLY | O_APPEND, 0644); + ssize_t w; + size_t off = 0; + if (fd < 0) { + fprintf(stderr, "Cannot append to %s: %s\n", path, strerror(errno)); + return -1; + } + while (off < pub_len) { + w = write(fd, pub + off, pub_len - off); + if (w <= 0) { + close(fd); + fprintf(stderr, "Cannot append public key to %s: %s\n", path, strerror(errno)); + return -1; + } + off += (size_t)w; + } + if (close(fd) != 0) { + fprintf(stderr, "Cannot close %s: %s\n", path, strerror(errno)); + return -1; + } + } + + return 0; +} + +static void usage(const char *name) +{ + fprintf(stderr, "%s, generate ODoH X25519 key material in dohd formats.\n", name); + fprintf(stderr, "Usage: %s [-s secret.bin] [-p public.bin] [-c odoh.config]\n", name); + fprintf(stderr, "Defaults: secret=odoh-target.secret public=odoh-target.public config=odoh-target.config\n"); +} + +int main(int argc, char *argv[]) +{ + const char *secret_path = "odoh-target.secret"; + const char *public_path = "odoh-target.public"; + const char *config_path = "odoh-target.config"; + WC_RNG rng; + curve25519_key key; + uint8_t priv[CURVE25519_KEYSIZE]; + uint8_t pub[CURVE25519_PUB_KEY_SIZE]; + word32 priv_len = sizeof(priv); + word32 pub_len = sizeof(pub); + int c; + + while ((c = getopt(argc, argv, "hs:p:c:")) >= 0) { + switch (c) { + case 'h': + usage(argv[0]); + return 0; + case 's': + secret_path = optarg; + break; + case 'p': + public_path = optarg; + break; + case 'c': + config_path = optarg; + break; + default: + usage(argv[0]); + return 2; + } + } + + if (wc_InitRng(&rng) != 0) { + fprintf(stderr, "wc_InitRng failed\n"); + return 1; + } + if (wc_curve25519_init(&key) != 0) { + wc_FreeRng(&rng); + fprintf(stderr, "wc_curve25519_init failed\n"); + return 1; + } + if (wc_curve25519_make_key(&rng, CURVE25519_KEYSIZE, &key) != 0) { + wc_curve25519_free(&key); + wc_FreeRng(&rng); + fprintf(stderr, "wc_curve25519_make_key failed\n"); + return 1; + } + if (wc_curve25519_export_key_raw(&key, priv, &priv_len, pub, &pub_len) != 0) { + wc_curve25519_free(&key); + wc_FreeRng(&rng); + fprintf(stderr, "wc_curve25519_export_key_raw failed\n"); + return 1; + } + wc_curve25519_free(&key); + wc_FreeRng(&rng); + + if (priv_len != CURVE25519_KEYSIZE || pub_len != CURVE25519_PUB_KEY_SIZE) { + fprintf(stderr, "Unexpected key sizes: priv=%u pub=%u\n", priv_len, pub_len); + return 1; + } + + if (write_file(secret_path, priv, priv_len, 0600) != 0) + return 1; + if (write_file(public_path, pub, pub_len, 0644) != 0) + return 1; + if (write_odoh_config(config_path, pub, pub_len) != 0) + return 1; + + fprintf(stderr, "Generated:\n"); + fprintf(stderr, " secret (raw 32 bytes): %s\n", secret_path); + fprintf(stderr, " public (raw 32 bytes): %s\n", public_path); + fprintf(stderr, " ODoH config blob : %s\n", config_path); + fprintf(stderr, "HPKE suite: KEM=0x%04x KDF=0x%04x AEAD=0x%04x\n", + DHKEM_X25519_HKDF_SHA256, HKDF_SHA256, HPKE_AES_128_GCM); + + return 0; +}