Files
SUB-OS/net/http_client.c
T
subhobhai943andClaude Opus 4.8 8a55ac345b feat(crypto,net): TLS 1.3 client -- SUB-OS can now browse HTTPS sites
The browser could only reach plain HTTP, which is almost none of the real
web. This adds a from-scratch TLS 1.3 client so it can fetch https:// pages,
including real sites on the open internet.

Crypto primitives (all integer-only, freestanding, verified on the host
against their RFC test vectors before going in the kernel):
  - crypto/x25519.c            X25519 ECDH (RFC 7748), compact TweetNaCl field
                               arithmetic.
  - crypto/chacha20poly1305.c  ChaCha20 + Poly1305 AEAD (RFC 8439).
  - crypto/hkdf.c              HMAC-SHA256, HKDF (RFC 2104/5869) and the TLS 1.3
                               HKDF-Expand-Label / Derive-Secret helpers, on top
                               of the existing SHA-256.
A host harness checked every one against its published vectors (X25519 s6.1,
ChaCha20 s2.4.2, AEAD s2.8.2 incl. tamper rejection, HKDF TC1) -- all pass.

TLS 1.3 client (net/tls.c), single suite TLS_CHACHA20_POLY1305_SHA256 over
X25519: ClientHello (with SNI and ALPN http/1.1) -> ServerHello -> the RFC 8446
key schedule over a running transcript hash -> decrypt the server flight ->
send Finished -> switch to application keys. A small record layer handles the
plaintext-then-AEAD split and the middlebox ChangeCipherSpec. All large buffers
live in the heap-allocated connection, never on the 16 KB worker stack.

http_client routes https:// through TLS instead of raw TCP, so the async
worker, webget, netcheck and the GUI Web app all get HTTPS for free; the
redirect follower now follows http->https upgrades. The Web app's default page
is now https://example.com/.

Verified end to end on x86_64: the handshake interoperates with openssl
s_server (TLS 1.3, ChaCha20) -- GET returned HTTP 200 with the decrypted body
-- and, over QEMU's NAT, `webget https://example.com/` and the GUI Web app both
loaded the real Example Domain page served by Cloudflare over TLS 1.3 (HTTP
200, 828 bytes). The server only accepts our Finished if the transcript hash
and key schedule match byte-for-byte, so a 200 is proof the whole stack is
correct. Builds clean on x86_64, aarch64 and armv8i.

SECURITY CAVEAT, stated plainly: the server certificate is NOT verified. The
channel is encrypted and interoperates, but a man-in-the-middle could
impersonate the peer. Real certificate-chain validation (X.509 parsing,
signature checks, a CA bundle, hostname matching) is a large separate piece and
is not built here. This is fine for a hobby OS over a trusted local NAT; it is
not fit to protect real secrets. Also: TLS 1.3 only, one cipher suite, and
still no JavaScript/media -- so JS-heavy or app-style sites (YouTube) remain
out of reach; this makes static/text HTTPS pages readable.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Fh5z6V7nGPbHvqVG5p4CXX
2026-08-27 18:08:51 +05:30

374 lines
13 KiB
C

/*
* Asynchronous HTTP/1.0 client for SUB-OS.
*
* One worker thread, created at boot, services a single-slot mailbox. A
* requester parses a URL into a job, drops it in the mailbox, and polls the
* job's state; the worker performs the blocking DNS + TCP fetch and marks the
* job DONE or ERROR. Because the blocking happens on the worker rather than in
* the caller, the GUI compositor keeps running at full frame rate during a
* fetch that may take seconds.
*
* See http_client.h for the ownership rules; the two critical sections that
* enforce them (worker completion and requester release) run with interrupts
* disabled, which on this single-CPU kernel makes the hand-off indivisible.
*/
#include <net/http_client.h>
#include <net/tcp.h>
#include <net/tls.h>
#include <net/dns.h>
#include <net/net.h>
#include <mm/kmalloc.h>
#include <lib/string.h>
#include <lib/printf.h>
#include <kernel/printk.h>
#include <kernel/task.h>
#include <kernel/sched.h>
#include <arch/arch.h>
#define HTTP_CONNECT_TIMEOUT_MS 4000
#define HTTP_RECV_TIMEOUT_MS 3000
static task_t* worker_task = NULL;
static http_fetch_t* volatile mailbox = NULL; // job awaiting the worker
// ===========================================================================
// URL parsing
//
// Accepts host, host/path, host:port/path, with an optional http:// prefix.
// Anything it cannot make sense of leaves the job unusable and is reported as
// an error rather than guessed at.
// ===========================================================================
// Parse an absolute URL into its parts. Returns false only when there is no
// host to be found. `is_https` reports the scheme so the caller can refuse a
// TLS target cleanly rather than speaking plaintext to port 443.
static bool split_url(const char* url, char* host, size_t host_cap,
char* path, size_t path_cap, uint16_t* port, bool* is_https) {
host[0] = '\0';
strcpy(path, "/");
*port = 80;
*is_https = false;
if (!url) return false;
if (strncmp(url, "http://", 7) == 0) {
url += 7;
} else if (strncmp(url, "https://", 8) == 0) {
url += 8;
*is_https = true;
*port = 443;
}
// Host (and optional :port) up to the first '/'.
char hostport[192];
int i = 0;
while (url[i] && url[i] != '/' && i < (int)sizeof(hostport) - 1) {
hostport[i] = url[i];
i++;
}
hostport[i] = '\0';
const char* rest = url + i; // "" or "/path..."
char* colon = strchr(hostport, ':');
if (colon) {
*colon = '\0';
int p = 0;
for (const char* c = colon + 1; *c >= '0' && *c <= '9'; c++) p = p * 10 + (*c - '0');
if (p > 0 && p < 65536) *port = (uint16_t)p;
}
strncpy(host, hostport, host_cap - 1);
host[host_cap - 1] = '\0';
if (rest[0] == '/') {
strncpy(path, rest, path_cap - 1);
path[path_cap - 1] = '\0';
}
return host[0] != '\0';
}
static void parse_url(const char* url, http_fetch_t* f) {
split_url(url, f->host, sizeof(f->host), f->path, sizeof(f->path), &f->port, &f->tls);
}
// Find a header's value in the response, case-insensitively. Copies the value
// (trimmed, up to the CRLF) into out. Returns false if the header is absent.
static bool find_header(const char* buf, int len, const char* name,
char* out, size_t out_cap) {
size_t nlen = strlen(name);
// Scan line by line; the status line is skipped naturally (no match).
for (int i = 0; i + (int)nlen < len; i++) {
if (i != 0 && buf[i - 1] != '\n') continue; // only at a line start
// Case-insensitive compare of the header name.
size_t k = 0;
while (k < nlen) {
char a = buf[i + k], b = name[k];
if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a');
if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a');
if (a != b) break;
k++;
}
if (k != nlen || buf[i + k] != ':') continue;
const char* v = buf + i + nlen + 1;
const char* end = buf + len;
while (v < end && (*v == ' ' || *v == '\t')) v++;
size_t o = 0;
while (v < end && *v != '\r' && *v != '\n' && o < out_cap - 1) out[o++] = *v++;
out[o] = '\0';
return true;
}
return false;
}
// Resolve a Location value against the current request. Absolute URLs replace
// host/port/path; a path-only Location keeps the host. Returns false and sets
// f->err when the target is HTTPS, which this TLS-less client cannot follow.
static bool apply_redirect(http_fetch_t* f, const char* location) {
if (location[0] == '/' || location[0] == '\0') {
// Same host, new path.
strncpy(f->path, location[0] ? location : "/", sizeof(f->path) - 1);
f->path[sizeof(f->path) - 1] = '\0';
return true;
}
if (strncmp(location, "http://", 7) != 0 && strncmp(location, "https://", 8) != 0) {
// A bare relative reference; treat it as a rooted path.
snprintf(f->path, sizeof(f->path), "/%s", location);
return true;
}
bool https;
char host[128], path[256];
uint16_t port;
if (!split_url(location, host, sizeof(host), path, sizeof(path), &port, &https)) {
snprintf(f->err, sizeof(f->err), "redirect to a URL we cannot parse");
return false;
}
strcpy(f->host, host);
strcpy(f->path, path);
f->port = port;
f->tls = https; // follow http->https upgrades; TLS handles it
return true;
}
// Parse the numeric status out of "HTTP/1.x NNN ...", or 0 if absent.
static int parse_status(const char* buf, int len) {
if (len < 12 || strncmp(buf, "HTTP/", 5) != 0) return 0;
const char* p = buf;
const char* end = buf + len;
while (p < end && *p != ' ') p++; // skip "HTTP/1.x"
while (p < end && *p == ' ') p++;
int code = 0;
while (p < end && *p >= '0' && *p <= '9') code = code * 10 + (*p++ - '0');
return code;
}
// ===========================================================================
// The fetch itself, run on the worker thread.
//
// It fills in the result fields but deliberately does NOT touch f->state:
// publishing the terminal state is the finalize step's job, done under
// interrupts-off. If do_fetch flipped the state itself, the requester could
// observe DONE and free the job in the instant before finalize ran, and
// finalize would then read freed memory. Returning the state instead keeps the
// job private to the worker until the atomic hand-off.
// ===========================================================================
#define HTTP_MAX_REDIRECTS 5
// Perform one request/response against the current host/path/port, over TLS
// when the URL is https. Returns the HTTP status code, or a negative error
// code; the raw response lands in f->buf.
static int do_one_request(http_fetch_t* f) {
f->ip = ip_parse(f->host);
if (f->ip == 0) f->ip = dns_resolve(f->host);
if (f->ip == 0) {
snprintf(f->err, sizeof(f->err), "cannot resolve %s", f->host);
return -1;
}
char req[512];
int rl = snprintf(req, sizeof(req),
"GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: SUB-OS/webfetch\r\n"
"Connection: close\r\n\r\n", f->path, f->host);
f->len = 0;
if (f->tls) {
tls_conn_t* c = tls_connect(f->host, f->ip, f->port, HTTP_CONNECT_TIMEOUT_MS);
if (!c) {
snprintf(f->err, sizeof(f->err), "TLS to %s:%u failed: %s",
f->host, f->port, tls_last_error());
return -1;
}
if (tls_send(c, req, rl) < 0) {
snprintf(f->err, sizeof(f->err), "TLS send failed");
tls_close(c);
return -1;
}
while (f->len < f->cap - 1) {
int n = tls_recv(c, f->buf + f->len, f->cap - 1 - f->len, HTTP_RECV_TIMEOUT_MS);
if (n <= 0) break;
f->len += n;
}
tls_close(c);
} else {
tcp_conn_t* c = tcp_connect(f->ip, f->port, HTTP_CONNECT_TIMEOUT_MS);
if (!c) {
snprintf(f->err, sizeof(f->err), "connect to %s:%u refused/timed out",
f->host, f->port);
return -1;
}
if (tcp_send(c, req, (uint16_t)rl) < 0) {
snprintf(f->err, sizeof(f->err), "send failed");
tcp_close(c);
return -1;
}
while (f->len < f->cap - 1) {
int n = tcp_recv(c, f->buf + f->len, (uint16_t)(f->cap - 1 - f->len),
HTTP_RECV_TIMEOUT_MS);
if (n <= 0) break;
f->len += n;
}
tcp_close(c);
}
f->buf[f->len] = '\0';
if (f->len == 0) {
snprintf(f->err, sizeof(f->err), "no data received");
return -1;
}
return parse_status(f->buf, f->len);
}
// Fills in the result fields but deliberately does NOT touch f->state:
// publishing the terminal state is finalize's job, done under interrupts-off.
// Returning the state keeps the job private to the worker until that hand-off.
//
// A 3xx response with a Location header is followed, up to a bounded number of
// hops, so a real-world URL that redirects (a bare host to a path, http to
// https, a canonical host) resolves to actual content -- including the very
// common http->https upgrade, now that TLS is available.
static int do_fetch(http_fetch_t* f) {
uint64_t t0 = pit_get_ticks();
f->redirects = 0;
for (;;) {
int status = do_one_request(f);
if (status < 0) return HTTP_STATE_ERROR; // f->err already set
bool is_redirect = (status == 301 || status == 302 || status == 303 ||
status == 307 || status == 308);
char location[512];
if (is_redirect && f->redirects < HTTP_MAX_REDIRECTS &&
find_header(f->buf, f->len, "Location", location, sizeof(location))) {
if (!apply_redirect(f, location)) return HTTP_STATE_ERROR;
f->redirects++;
continue;
}
f->status_code = status;
f->elapsed_ms = (uint32_t)((pit_get_ticks() - t0) * 10);
return HTTP_STATE_DONE;
}
}
static void free_job(http_fetch_t* f) {
if (!f) return;
if (f->buf) kfree(f->buf);
kfree(f);
}
// Publish the terminal state, or free the job if the requester walked away
// while the fetch was in flight. Interrupts are off across the decision so it
// serialises against http_fetch_release: exactly one of the two frees the job,
// and the requester never sees a non-RUNNING state for a job it has released.
static void finalize(http_fetch_t* f, int result) {
unsigned long fl = arch_irq_save();
bool abandoned = f->abandoned;
if (!abandoned) f->state = result;
arch_irq_restore(fl);
if (abandoned) free_job(f);
}
static void http_worker_main(void* arg) {
(void)arg;
for (;;) {
unsigned long fl = arch_irq_save();
http_fetch_t* job = mailbox;
mailbox = NULL;
arch_irq_restore(fl);
if (!job) {
// Idle: yield rather than halt. Halting would hold the CPU until a
// timer tick and starve the compositor between ticks; a cooperative
// yield hands the CPU straight back and the two ping-pong cheaply.
net_wait();
continue;
}
int result = do_fetch(job);
finalize(job, result);
}
}
// ===========================================================================
// Public API
// ===========================================================================
void http_client_init(void) {
mailbox = NULL;
worker_task = task_create("http-worker", http_worker_main, NULL, 0);
if (worker_task) {
printk(KERN_INFO "NET: async HTTP client worker online\n");
} else {
printk(KERN_WARNING "NET: async HTTP client worker failed to start\n");
}
}
bool http_client_busy(void) {
return mailbox != NULL;
}
http_fetch_t* http_fetch_start(const char* url, int cap) {
if (!worker_task || !url || cap < 64) return NULL;
if (mailbox) return NULL; // a fetch is already queued
http_fetch_t* f = (http_fetch_t*)kzalloc(sizeof(http_fetch_t));
if (!f) return NULL;
f->buf = (char*)kzalloc(cap);
if (!f->buf) { kfree(f); return NULL; }
f->cap = cap;
f->state = HTTP_STATE_RUNNING;
parse_url(url, f);
if (f->host[0] == '\0') {
kfree(f->buf); kfree(f);
return NULL;
}
// Hand the job to the worker. Re-check the mailbox under interrupts-off so
// two near-simultaneous starts cannot both install a job.
unsigned long fl = arch_irq_save();
if (mailbox) { arch_irq_restore(fl); kfree(f->buf); kfree(f); return NULL; }
mailbox = f;
arch_irq_restore(fl);
return f;
}
void http_fetch_release(http_fetch_t* f) {
if (!f) return;
unsigned long fl = arch_irq_save();
if (f->state == HTTP_STATE_RUNNING) {
// The worker still owns it (queued or in flight): let it free the job
// when it finishes rather than pulling the memory out from under it.
f->abandoned = true;
arch_irq_restore(fl);
return;
}
arch_irq_restore(fl);
free_job(f);
}