1651d2456b48

require trusted HTTPS for Koh 3 releases and remote traffic (3.4.6)

time
2026-10-03 22:52 UTC · 1 week ago
machine
Junes-MacBook-Pro.local
agent
gpt-6
session
release koh three
parent
6123d2cfa911
9 files VERSION+1 −1build.sh+1 −4src/cli/cmd_remote.v+4 −0src/remote/remote.v+20 −0src/remote/s3.v+2 −0src/remote/web.v+6 −0src/update/update.v+13 −2tests/update_tls_test.py+51 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.4.41+3.4.6

view file

build.sh+1 −4
@@ -17,10 +17,7 @@1717     compiler="$PWD/build/cc-$target"1818     printf '#!/bin/sh\nexec zig cc -target %s "$@"\n' "$triple" > "$compiler"1919     chmod 755 "$compiler"20-    v -prod -d use_bundled_libgc -os linux -arch "$arch" -musl -cc "$compiler" -o "build/releases/koh-$target" src/koh.v21-    zig objcopy --strip-all "build/releases/koh-$target" "build/releases/koh-$target.stripped"22-    chmod 755 "build/releases/koh-$target.stripped"23-    mv -f "build/releases/koh-$target.stripped" "build/releases/koh-$target"20+    v -prod -d use_bundled_libgc -os linux -arch "$arch" -musl -cc "$compiler" -ldflags "-s" -o "build/releases/koh-$target" src/koh.v2421     ;;2522   *) echo "unsupported release target: $target" >&2; exit 1 ;;2623 esac

view file

src/cli/cmd_remote.v+4 −0
@@ -171,6 +171,8 @@171171 	// Redirects are refused: a 3xx would hand the one-time code to whatever it points at.172172 	response := http.fetch(173173 		url:            '${host}/api/connect'174+		validate:       true175+		verify:         remote.tls_roots('${host}/api/connect') or { return refuse(args.json(), err.msg()) }174176 		method:         .post175177 		header:         http.new_header(176178 			key:   .content_type@@ -185,6 +187,8 @@185187 		// are told apart by asking for the connect page: 302 means the endpoint is there.186188 		probe := http.fetch(187189 			url:            connect_url190+			validate:       true191+			verify:         remote.tls_roots(connect_url) or { return refuse(args.json(), err.msg()) }188192 			method:         .get189193 			enable_http2:   false190194 			allow_redirect: false

view file

src/remote/remote.v+20 −0
@@ -8,6 +8,7 @@88 module remote99 1010 import rand11+import os1112 1213 // get not finding a key is a normal answer, not an error, so `Object.found`1314 // carries it and errors mean real trouble (a refused connection, bad keys).@@ -56,4 +57,23 @@5657 	}5758 	// Strip needs delete, so a remote that cannot delete fails here.5859 	backend.delete(key)!60+}61+62+/// Find the system CA bundle for HTTPS; plain HTTP needs no TLS roots.63+pub fn tls_roots(url string) !string {64+	mut roots := ''65+	if url.starts_with('https://') {66+		for path in [os.getenv('SSL_CERT_FILE'), '/etc/ssl/cert.pem',67+			'/etc/ssl/certs/ca-certificates.crt', '/etc/ssl/certs/ca-bundle.crt',68+			'/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/ca-bundle.pem'] {69+			if path != '' && os.is_file(path) {70+				roots = path71+				break72+			}73+		}74+		if roots == '' {75+			return error('no system CA bundle found — set SSL_CERT_FILE to your trusted CA bundle')76+		}77+	}78+	return roots5979 }

view file

src/remote/s3.v+2 −0
@@ -158,6 +158,8 @@158158 		// request opens its own connection anyway, so HTTP/2 gains nothing.159159 		response := http.fetch(160160 			url:          url161+			validate:     true162+			verify:       tls_roots(url)!161163 			method:       method_from(method)162164 			header:       outbound163165 			data:         body.bytestr()

view file

src/remote/web.v+6 −0
@@ -47,6 +47,8 @@4747 pub fn (mut w Web) get(key string) !Object {4848 	response := http.fetch(4949 		url:            w.url(key)50+		validate:       true51+		verify:         tls_roots(w.url(key))!5052 		method:         .get5153 		enable_http2:   false5254 		allow_redirect: false@@ -70,6 +72,8 @@7072 	on_body := w.on_body7173 	response := http.fetch(7274 		url:                w.url(key)75+		validate:           true76+		verify:             tls_roots(w.url(key))!7377 		method:             .get7478 		enable_http2:       false7579 		allow_redirect:     false@@ -99,6 +103,8 @@99103 pub fn (mut w Web) list(prefix string) ![]string {100104 	response := http.fetch(101105 		url:            w.url(prefix)106+		validate:       true107+		verify:         tls_roots(w.url(prefix))!102108 		method:         .get103109 		enable_http2:   false104110 		allow_redirect: false

view file

src/update/update.v+13 −2
@@ -11,6 +11,7 @@1111 import json21212 import net.http1313 import os14+import src.remote1415 1516 pub const default_source = 'https://kepr.uk/koh/releases/latest.json'1617 pub const source_env = 'KOH_UPDATE_SOURCE'@@ -63,7 +64,12 @@6364 // do" is not one of them, so it is a field, not a failed call.6465 pub fn check(now string, platform string) !Checked {6566 	url := source()66-	response := http.get(url) or { return error('could not reach ${url}: ${err.msg()}') }67+	response := http.fetch(68+		url:          url69+		validate:     true70+		verify:       remote.tls_roots(url)!71+		enable_http2: false72+	) or { return error('could not reach ${url}: ${err.msg()}') }6773 	if response.status_code != 200 {6874 		return error('${url} answered ${response.status_code}')6975 	}@@ -165,7 +171,12 @@165171 		}166172 		return bytes167173 	}168-	response := http.get(url) or { return error('could not download ${url}: ${err.msg()}') }174+	response := http.fetch(175+		url:          url176+		validate:     true177+		verify:       remote.tls_roots(url)!178+		enable_http2: false179+	) or { return error('could not download ${url}: ${err.msg()}') }169180 	if response.status_code != 200 {170181 		return error('${url} answered ${response.status_code}')171182 	}

view file

tests/update_tls_test.py+51 −0
@@ -0,0 +1,51 @@1+"""Both update requests must reject an untrusted TLS peer before trusting its metadata or bytes."""2+import http.server3+import json4+import os5+from pathlib import Path6+import ssl7+import subprocess8+import sys9+import tempfile10+import threading11+12+binary = Path(sys.argv[1]).resolve()13+work = Path(tempfile.mkdtemp(prefix='koh-update-tls-'))14+subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=localhost', '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', str(work / 'key.pem'), '-out', str(work / 'cert.pem')], check=True, capture_output=True)15+platforms = ['macos-aarch64', 'macos-x86_64', 'linux-aarch64', 'linux-x86_64']16+17+class Handler(http.server.BaseHTTPRequestHandler):18+    def do_GET(self):19+        body = json.dumps({'version': '999.0.0', 'platforms': {p: {'url': tls_url, 'blake3': '0' * 64} for p in platforms}}).encode()20+        self.send_response(200)21+        self.send_header('Content-Length', str(len(body)))22+        self.end_headers()23+        self.wfile.write(body)24+    def log_message(self, *_):25+        pass26+27+secure = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)28+context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)29+context.load_cert_chain(work / 'cert.pem', work / 'key.pem')30+secure.socket = context.wrap_socket(secure.socket, server_side=True)31+tls_url = f'https://127.0.0.1:{secure.server_port}/release'32+plain = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)33+for server in (secure, plain):34+    threading.Thread(target=server.serve_forever, daemon=True).start()35+try:36+    trusted = dict(os.environ, HOME=str(work), KOH_UPDATE_SOURCE=tls_url, SSL_CERT_FILE=str(work / 'cert.pem'))37+    good = subprocess.run([str(binary), 'update', '--check', '--json'], env=trusted, capture_output=True, text=True, timeout=30)38+    assert good.returncode == 1 and json.loads(good.stdout)['latest'] == '999.0.0', (good.stdout, good.stderr)39+    for url, args, message in [(tls_url, ['--check', '--json'], 'could not reach'), (f'http://127.0.0.1:{plain.server_port}/release', ['--yes', '--json'], 'could not download')]:40+        env = dict(os.environ, HOME=str(work), KOH_UPDATE_SOURCE=url)41+        result = subprocess.run([str(binary), 'update', *args], env=env, capture_output=True, text=True, timeout=30)42+        assert result.returncode == 1 and message in result.stderr, (result.stdout, result.stderr)43+    for args in [['remote', f'https://127.0.0.1:{secure.server_port}', '--code', 'x', '--json'], ['steal', f'https://127.0.0.1:{secure.server_port}/nina', '--json'], ['remote', 's3', tls_url, 'test', 'test', '--json']]:44+        isolated = dict(os.environ, HOME=str(work), KOH_ACCESS_KEY='test', KOH_SECRET_KEY='test')45+        isolated.pop('SSL_CERT_FILE', None)46+        result = subprocess.run([str(binary), *args], cwd=work, env=isolated, capture_output=True, text=True, timeout=45)47+        assert result.returncode == 1 and 'handshake' in result.stderr, (args, result.stdout, result.stderr)48+    print('PASS: trusted TLS accepted; metadata, binaries, connect, steal and S3 reject self-signed TLS')49+finally:50+    secure.shutdown()51+    plain.shutdown()

view file

v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 	name: 'koh3'3-	version: '3.4.4'3+	version: '3.4.6'44 	description: 'One SQLite file per repo, one line of history.'55 	license: 'MIT'66 }

view file