What a browser needs
A browser dialing WebTransport by IP literal (no DNS name, no CA) needs
three things from the server identity, and pins the connection to the
third: a SAN IP entry in the self-signed cert, a validity window the cert
is actually inside of, and the cert’s own SHA-256 hash
(serverCertificateHashes).
Code
#define WIRED_MAIN#include "wired.h"
/* A real browser dialing WebTransport by IP (no DNS name) needs three * things from the server identity: a SAN IP address entry in the self- * signed cert (RFC 5280 4.2.1.6), a validity window the cert is actually * inside of (now_secs), and the cert's SHA-256 to pin via * serverCertificateHashes. now_secs=0 keeps the SDK's fixed 2020-2030 * window, so this demo stays deterministic without a real clock. */static const u8 SAN_IPV4[4] = {127, 0, 0, 1};
int wired_main(int argc, char** argv) { /* The same fixed demo identity as the HTTP/3 hello page. */ static wired_srvboot_demo_keys keys; wired_srvboot_id id; wired_srvboot_demo(&id, &keys, 0x50, "guide-wt"); id.max_datagram_frame_size = 65535; /* WebTransport requires DATAGRAM */ id.san_ipv4 = SAN_IPV4; id.now_secs = 0; /* the SDK's fixed 2020-2030 window */ wired_srvboot_log_fingerprint(2, &id);
wired_srvrun_opt opt = {0}; opt.incoming_cpu = -1;
u16 port = (u16)wired_cliargs_int(argc, argv, "--port", 4433); wired_srvrun_handler h = {0}; wired_srvrun_obs obs = {0}; return wired_server_run_opt(port, &id, h, obs, &opt) ? 0 : 1;}Client
package main
import ( "context" "crypto/sha256" "crypto/tls" "fmt" "net/http" "os"
"github.com/quic-go/quic-go" "github.com/quic-go/quic-go/http3" "github.com/quic-go/webtransport-go")
func main() { ctx := context.Background()
// A browser verifies serverCertificateHashes against the leaf cert's // own SHA-256, computed from the bytes it actually received -- this is // that same check, done by hand. tlsConf := &tls.Config{InsecureSkipVerify: true, NextProtos: []string{http3.NextProtoH3}} conn, err := quic.DialAddr(ctx, os.Args[1][len("https://"):], tlsConf, nil) if err != nil { panic(err) } leaf := conn.ConnectionState().TLS.PeerCertificates[0].Raw fmt.Printf("cert sha256: %x\n", sha256.Sum256(leaf)) conn.CloseWithError(0, "")
// Then the actual WebTransport CONNECT a browser would make to the // same IP literal. var wtConn *quic.Conn // kept so main can close it before exiting d := webtransport.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, DialAddr: func(ctx context.Context, addr string, t *tls.Config, q *quic.Config) (*quic.Conn, error) { c, err := quic.DialAddrEarly(ctx, addr, t, q) wtConn = c return c, err }, } rsp, _, err := d.Dial(ctx, os.Args[1]+"/chat", http.Header{}) if err != nil { panic(err) } fmt.Println("status", rsp.StatusCode) wtConn.CloseWithError(0, "") // ending the connection ends its session too}Output
$ ./wt-browser --port 4433cert sha-256 fingerprint: fd:ef:07:1f:c8:07:4b:45:60:67:f3:a0:52:de:62:e8:86:03:32:12:04:61:ce:77:7f:25:4f:0f:9b:77:1c:e8conn closed by peer (CONNECTION_CLOSE)conn closed by peer (CONNECTION_CLOSE)$ go run . https://127.0.0.1:4433cert sha256: fdef071fc8074b456067f3a052de62e8860332120461ce777f254f0f9b771ce8status 200How it works
Section titled “How it works”id.san_ipv4adds an RFC 5280 4.2.1.6 SAN IP address entry (here127.0.0.1) to the self-signed cert; without it a browser connecting to a bare IP rejects the cert (its SAN would carry onlydNSName=localhost).id.now_secsanchors the cert’s validity window;0keeps the SDK’s fixed 2020-2030 window, deterministic for this page. A real deployment passeswired_clock_epoch_secs()instead (seeexamples/moqt_chat).wired_srvboot_log_fingerprint(2, &id)builds the exact certificate the server will present, hashes it with SHA-256, and logs it the same way a browser’sserverCertificateHashesentry is computed (wired_srvboot_cert_sha256returns the raw 32 bytes instead) – the client page does the identical hash over the bytes it actually received, and both lines match.- With no
wt_resource_checkregistered, the Extended CONNECT to/chatproceeds straight through (the same defaultwt-sessiondescribes).