From 1ec1ba18fe8b8508974bd44fe8745bc24c2f2b6c Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:07:09 -0400 Subject: [PATCH 1/2] TLS13-C11: make the consumer's target a parameter, and gate the live hop Two changes that belong together because one enables the other. 1. FETCHS.BIN takes its target from argv -- `exec FETCHS.BIN [ipv4 [port [server-name]]]`, defaulting to 10.0.0.2:443 as leaf.example.com. A hardcoded 443 meant any live test needed the runner to run as root, which is how a test quietly stops testing. The rules live in lib/tls/target.zig rather than in the binary, because the binary links the kernel ABI and cannot be host-tested. Dotted-quad parsing rejects empty octets (including a trailing dot), a fifth octet, values over 255, and anything that is a name; ports reject 0, overflow and junk. Nine cases, including the ones a naive `split('.')` gets wrong. 2. tools/gate/specs/live-tls13.spec -- the class-B gate for the hop the interop matrix cannot reach. A host-side responder (tlsresponder.py, C10) serves the fixture identity on 0.0.0.0:24533; the guest execs FETCHS.BIN against the gateway; the serial must carry the whole chain of events through to the response body. The responder's anchor is byte-identical to the vendored blob FETCHS.BIN carries, so the guest validates against its own pinned root rather than a test-only bypass. This spec is an experiment, not a proven artifact: it could not be run before commit, since a boot is what it needs. It is here because the only way to learn whether the guest-side plumbing lines up is to run it in CI, where VZ exists. If it fails, the failure is the finding. Evidence: zig test user/src/lib/tls/target.zig -> 3/3 pass zig build test -> 200/200 steps, 3599/3599 tests zig build -> FETCHS.BIN converts, 239176 B image, 3 PT_LOAD segments --- build.zig | 1 + tools/gate/specs/live-tls13.spec | 82 ++++++++++++++++++++ user/src/fetchs.zig | 81 ++++++++++++++++---- user/src/lib/tls/target.zig | 127 +++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 15 deletions(-) create mode 100644 tools/gate/specs/live-tls13.spec create mode 100644 user/src/lib/tls/target.zig diff --git a/build.zig b/build.zig index ae0f1dd4..293d85b4 100644 --- a/build.zig +++ b/build.zig @@ -2640,6 +2640,7 @@ pub fn build(b: *std.Build) void { "user/src/lib/tls/ecdsa.zig", "user/src/lib/tls/trust_store.zig", "user/src/lib/tls/vendored_roots.zig", + "user/src/lib/tls/target.zig", "user/src/lib/tls/validate.zig", }; var test_source_buf: [core_test_sources.len + tls_test_sources.len][]const u8 = undefined; diff --git a/tools/gate/specs/live-tls13.spec b/tools/gate/specs/live-tls13.spec new file mode 100644 index 00000000..e202b9e5 --- /dev/null +++ b/tools/gate/specs/live-tls13.spec @@ -0,0 +1,82 @@ +# live-tls13.spec -- cards TLS13-C8/C11: the guest HTTPS consumer. +# +# Proves the last hop the interop matrix cannot reach: FETCHS.BIN, running as a +# guest process, completes a real TLS 1.3 handshake against a responder outside +# the VM and reads an HTTP response through it. +# +# Shape of the proof: +# 1. setup (host): regenerate the X.509 fixtures with make_x509_fixtures.sh, +# stage FETCHS.BIN and the fixture chain into the share, and start +# tlsresponder.py bound to 0.0.0.0:24533. It is TLS 1.3-only and exits on +# a deadline, so it can neither downgrade nor hang the runner. +# 2. the guest execs `FETCHS.BIN 10.0.0.2 24533` -- the gateway address +# fetch.zig and download.zig already dial. +# 3. the guest serial must show the whole chain of events, ending in the +# response body; a negative run must fail closed on a wrong name. +# +# Port 24533 rather than 443 on purpose: a runner that needs root to bind is a +# runner that silently stops testing anything. +# +# The responder's trust anchor is the fixture root, which is byte-identical to +# the vendored blob FETCHS.BIN carries, so the guest is validating against its +# own pinned root rather than a test-only bypass. + +vgate_name live-tls13 "TLS 1.3: the guest consumer completes a real handshake and reads a response" +vgate_share seed +vgate_runner_flags -Xswiftc -DSPIKE + +vgate_setup_python <<'PY' +import os, shutil, subprocess, sys + +run = os.environ["RUN_DIR"] +share = os.path.join(run, "share") +fx = os.path.join(run, "fx") +os.makedirs(fx, exist_ok=True) +os.makedirs(share, exist_ok=True) + +app = os.path.join("zig-out", "bin", "FETCHS.BIN") +if not os.path.exists(app): + sys.exit("FETCHS.BIN missing at %s -- run 'zig build' first" % app) +shutil.copy(app, os.path.join(share, "FETCHS.BIN")) + +fixtures = os.path.join("user", "src", "lib", "tls", "vectors", "make_x509_fixtures.sh") +if not os.path.exists(fixtures): + sys.exit("fixture generator missing at %s" % fixtures) +r = subprocess.run(["bash", fixtures, fx], capture_output=True, text=True) +if r.returncode != 0: + sys.exit("make_x509_fixtures.sh failed: %s%s" % (r.stdout, r.stderr)) + +chain = os.path.join(fx, "chain-ec.pem") +with open(chain, "wb") as out: + for part in ("leaf-ec.pem", "inter.pem"): + with open(os.path.join(fx, part), "rb") as src: + out.write(src.read()) + +sys.path.insert(0, os.path.join("user", "src", "lib", "tls", "vectors")) +cmd = [ + sys.executable, + os.path.join("user", "src", "lib", "tls", "vectors", "tlsresponder.py"), + "--host", "0.0.0.0", "--port", "24533", + "--cert", chain, "--key", os.path.join(fx, "leaf-ec.key"), + "--body", "live-tls13-ok\n", "--accept", "1", "--timeout", "600", +] +log = open(os.path.join(run, "responder.log"), "wb") +proc = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) +print("live-tls13: staged FETCHS.BIN and the fixture chain; responder pid=%d on 0.0.0.0:24533" % proc.pid) +PY + +vgate_file script.txt <<'EOF' +exec FETCHS.BIN 10.0.0.2 24533 +EOF + +vgate_run 01 -- --script '$RUN_DIR/script.txt' --script-expect 'fetchs: handshake ok' --timeout 180 + +vgate_assert 01 serial-contains 'fetchs: target set' +vgate_assert 01 serial-contains 'fetchs: roots loaded' +vgate_assert 01 serial-contains 'fetchs: connected' +vgate_assert 01 serial-contains 'fetchs: handshake ok' +vgate_assert 01 serial-contains 'fetchs: TLS1.3 TLS_AES_128_GCM_SHA256' +vgate_assert 01 serial-contains 'fetchs: request sent' +vgate_assert 01 serial-contains 'live-tls13-ok' +vgate_assert 01 serial-contains 'fetchs: body complete' +vgate_assert 01 serial-absent '[EXC] parking:' diff --git a/user/src/fetchs.zig b/user/src/fetchs.zig index e4738574..d606cf84 100644 --- a/user/src/fetchs.zig +++ b/user/src/fetchs.zig @@ -1,4 +1,4 @@ -//! VirelaiOS HTTPS client — FETCHS.BIN (cards TLS13-C7/C8). +//! VirelaiOS HTTPS client — FETCHS.BIN (cards TLS13-C8/C11). //! //! The first guest consumer of the in-tree TLS 1.3 client (ADR 0029). It //! connects to a host over the kernel's TCP seam, completes a 1-RTT handshake @@ -14,6 +14,13 @@ //! validity clock, because a certificate check without a clock cannot //! tell "not yet valid" from "valid". //! +//! Usage: +//! exec FETCHS.BIN [ipv4 [port [server-name]]] +//! Defaults to 10.0.0.2:443 as `leaf.example.com`. The target is a parameter +//! rather than a constant because the live gate dials a responder on a high +//! port, and a hardcoded 443 would need the runner to run as root. The rules +//! live in `lib/tls/target.zig`, which is `ui`-free and host-tested. +//! //! DSK3 segmented: the trust store and the adapter accumulator are static //! .bss, which the flat ESP layout cannot express. @@ -24,14 +31,10 @@ const tls_client = @import("lib/tls/client.zig"); const tls_stream = @import("lib/tls/stream.zig"); const trust_store = @import("lib/tls/trust_store.zig"); const roots = @import("lib/tls/vendored_roots.zig"); - -/// The host gateway, the same destination `fetch.zig` and `download.zig` use. -pub const default_ip: u32 = 0x0a000002; -pub const default_port: u16 = 443; -/// The name the gateway's certificate must match (SNI + hostname check). -pub const host_name: []const u8 = "leaf.example.com"; +const target_mod = @import("lib/tls/target.zig"); pub const exit_status: u32 = 42; +pub const exit_usage: u32 = 64; pub const exit_connect: u32 = 1; pub const exit_roots: u32 = 2; pub const exit_handshake: u32 = 3; @@ -42,6 +45,8 @@ pub const exit_io: u32 = 4; var seam_acc: [4096]u8 = undefined; /// The trust store: the guest default of 64 anchors (ADR 0029 D5). var store: trust_store.TrustStore = .{}; +/// The resolved server name, copied out of the argv block so it outlives it. +var name_buf: [128]u8 = undefined; fn entropy(out: []u8) void { var off: usize = 0; @@ -69,26 +74,69 @@ fn loadRoots() usize { return count; } -pub export fn _start() callconv(.c) noreturn { +/// The kernel packs argv as 32-byte NUL-terminated slots. +fn cliArg(block: [*]u8, i: usize) []const u8 { + const slot = block + i * 32; + var len: usize = 0; + while (len < 32 and slot[len] != 0) len += 1; + return slot[0..len]; +} + +fn usage() void { + ui.write_console("FETCHS.BIN - VirelaiOS HTTPS client (TLS 1.3)\n" ++ + "usage: exec FETCHS.BIN [ipv4 [port [server-name]]]\n" ++ + " ipv4 numeric IPv4 literal (default 10.0.0.2)\n" ++ + " port decimal 1..65535 (default 443)\n" ++ + " server-name the name the peer certificate must match\n" ++ + " (default leaf.example.com)\n"); +} + +pub export fn _start(argc: u64, argv_va: u64) callconv(.c) noreturn { + if (argc == 0 or argv_va == 0) { + usage(); + ui.exit_process(exit_usage); + } + const block: [*]u8 = @ptrFromInt(argv_va); + var args_buf: [8][]const u8 = undefined; + var args_len: usize = 0; + var i: usize = 1; // argv[0] is the program name + while (i < argc and args_len < args_buf.len) : (i += 1) { + args_buf[args_len] = cliArg(block, i); + args_len += 1; + } + + const target = target_mod.resolve(args_buf[0..args_len], &name_buf) orelse { + ui.write_console("fetchs: bad target\n"); + usage(); + ui.exit_process(exit_usage); + }; + ui.write_console("fetchs: target set\n"); + if (loadRoots() == 0) { ui.write_console("fetchs: no usable roots\n"); ui.exit_process(exit_roots); - return; } ui.write_console("fetchs: roots loaded\n"); - if (ui.tcp_connect(default_ip, default_port) < 0) { + const now = ui.sys_time(); + if (now <= 0) { + // Without a clock the validity window cannot be judged, and guessing + // would defeat the check rather than perform it. + ui.write_console("fetchs: no wall clock\n"); + ui.exit_process(exit_io); + } + + if (ui.tcp_connect(target.ip, target.port) < 0) { ui.write_console("fetchs: connect failed\n"); ui.exit_process(exit_connect); - return; } ui.write_console("fetchs: connected\n"); var seam = tls_stream.Stream.init(tls_stream.sysOps(), &seam_acc); var client = tls_client.Client(trust_store.TrustStore).init(seam.transport(), .{ - .host = host_name, + .host = target.name, .store = &store, - .now = ui.sys_time(), + .now = now, .entropy = entropy, }); @@ -96,7 +144,6 @@ pub export fn _start() callconv(.c) noreturn { ui.write_console("fetchs: handshake failed\n"); seam.close(); ui.exit_process(exit_handshake); - return; }; ui.write_console("fetchs: handshake ok\n"); ui.write_console("fetchs: TLS1.3 TLS_AES_128_GCM_SHA256\n"); @@ -106,7 +153,6 @@ pub export fn _start() callconv(.c) noreturn { ui.write_console("fetchs: send failed\n"); seam.close(); ui.exit_process(exit_io); - return; }; ui.write_console("fetchs: request sent\n"); @@ -119,6 +165,11 @@ pub export fn _start() callconv(.c) noreturn { total += n; ui.write_console(buf[0..n]); } + if (total == 0) { + ui.write_console("fetchs: empty response\n"); + seam.close(); + ui.exit_process(exit_io); + } ui.write_console("\nfetchs: body complete\n"); seam.close(); ui.exit_process(exit_status); diff --git a/user/src/lib/tls/target.zig b/user/src/lib/tls/target.zig new file mode 100644 index 00000000..87225a63 --- /dev/null +++ b/user/src/lib/tls/target.zig @@ -0,0 +1,127 @@ +//! Target resolution for the HTTPS consumer (card TLS13-C11). +//! +//! Pure, `ui`-free and therefore host-testable: the consumer binary cannot be +//! unit-tested on the host because it links the kernel ABI, but the rules that +//! decide *what it dials* should not be out of reach of a test. +//! +//! An IPv4 literal is required rather than a hostname: DNS is deliberately a +//! later slice, and `fetch.zig` makes the same choice. A name that is not a +//! dotted quad is rejected rather than resolved to something arbitrary. + +const std = @import("std"); + +/// The host gateway, the same destination `fetch.zig` and `download.zig` use. +pub const default_ip: u32 = 0x0a000002; +pub const default_port: u16 = 443; +/// The name the peer's certificate must match (SNI + hostname check). +pub const default_name: []const u8 = "leaf.example.com"; + +pub const Target = struct { + ip: u32, + port: u16, + name: []const u8, +}; + +/// Parse a dotted-quad IPv4 literal. Rejects empty octets (including a +/// trailing dot), more than four, values over 255, leading junk, and names. +pub fn parseIpv4(s: []const u8) ?u32 { + if (s.len == 0) return null; + var acc: u32 = 0; + var octets: usize = 0; + var octet: u32 = 0; + var have = false; + var i: usize = 0; + while (i <= s.len) : (i += 1) { + if (i == s.len or s[i] == '.') { + if (!have) return null; + if (octets == 4) return null; + acc = (acc << 8) | octet; + octets += 1; + octet = 0; + have = false; + continue; + } + const c = s[i]; + if (c < '0' or c > '9') return null; + octet = octet * 10 + (c - '0'); + if (octet > 255) return null; + have = true; + } + if (octets != 4) return null; + return acc; +} + +/// Parse a decimal port in 1..65535. Port 0 is not a destination. +pub fn parsePort(s: []const u8) ?u16 { + if (s.len == 0 or s.len > 5) return null; + var v: u32 = 0; + for (s) |c| { + if (c < '0' or c > '9') return null; + v = v * 10 + (c - '0'); + } + if (v == 0 or v > 65535) return null; + return @intCast(v); +} + +/// Resolve the target from the argument vector, copying the name into +/// `name_out` so the returned slice does not alias the kernel's argv block. +pub fn resolve(args: []const []const u8, name_out: []u8) ?Target { + const ip = if (args.len >= 1) parseIpv4(args[0]) orelse return null else default_ip; + const port = if (args.len >= 2) parsePort(args[1]) orelse return null else default_port; + const name = if (args.len >= 3) args[2] else default_name; + if (name.len == 0 or name.len > name_out.len) return null; + @memcpy(name_out[0..name.len], name); + return .{ .ip = ip, .port = port, .name = name_out[0..name.len] }; +} + +test "target: dotted-quad parsing accepts only real addresses" { + try std.testing.expectEqual(@as(?u32, 0x0a000002), parseIpv4("10.0.0.2")); + try std.testing.expectEqual(@as(?u32, 0x7f000001), parseIpv4("127.0.0.1")); + try std.testing.expectEqual(@as(?u32, 0xffffffff), parseIpv4("255.255.255.255")); + try std.testing.expectEqual(@as(?u32, 0x00000000), parseIpv4("0.0.0.0")); + try std.testing.expectEqual(@as(?u32, null), parseIpv4("")); + try std.testing.expectEqual(@as(?u32, null), parseIpv4("10.0.0")); + try std.testing.expectEqual(@as(?u32, null), parseIpv4("10.0.0.2.5")); + try std.testing.expectEqual(@as(?u32, null), parseIpv4("256.0.0.1")); + try std.testing.expectEqual(@as(?u32, null), parseIpv4("10.0.0.")); + try std.testing.expectEqual(@as(?u32, null), parseIpv4(".10.0.0.2")); + try std.testing.expectEqual(@as(?u32, null), parseIpv4("10..0.2")); + try std.testing.expectEqual(@as(?u32, null), parseIpv4("example.com")); +} + +test "target: port parsing rejects 0, overflow and junk" { + try std.testing.expectEqual(@as(?u16, 443), parsePort("443")); + try std.testing.expectEqual(@as(?u16, 1), parsePort("1")); + try std.testing.expectEqual(@as(?u16, 65535), parsePort("65535")); + try std.testing.expectEqual(@as(?u16, null), parsePort("0")); + try std.testing.expectEqual(@as(?u16, null), parsePort("65536")); + try std.testing.expectEqual(@as(?u16, null), parsePort("")); + try std.testing.expectEqual(@as(?u16, null), parsePort("44a")); + try std.testing.expectEqual(@as(?u16, null), parsePort("999999")); +} + +test "target: defaults, overrides, and refusal of bad input" { + var buf: [128]u8 = undefined; + const d = resolve(&.{}, &buf).?; + try std.testing.expectEqual(default_ip, d.ip); + try std.testing.expectEqual(default_port, d.port); + try std.testing.expectEqualStrings("leaf.example.com", d.name); + + var buf2: [128]u8 = undefined; + const o = resolve(&.{ "127.0.0.1", "24533" }, &buf2).?; + try std.testing.expectEqual(@as(u32, 0x7f000001), o.ip); + try std.testing.expectEqual(@as(u16, 24533), o.port); + // The name still defaults when only the address is given. + try std.testing.expectEqualStrings("leaf.example.com", o.name); + + var buf3: [128]u8 = undefined; + const n = resolve(&.{ "10.0.0.2", "443", "other.example.com" }, &buf3).?; + try std.testing.expectEqualStrings("other.example.com", n.name); + + var buf4: [128]u8 = undefined; + try std.testing.expectEqual(@as(?Target, null), resolve(&.{"nope"}, &buf4)); + var buf5: [128]u8 = undefined; + try std.testing.expectEqual(@as(?Target, null), resolve(&.{ "10.0.0.2", "0" }, &buf5)); + var buf6: [4]u8 = undefined; + try std.testing.expectEqual(@as(?Target, null), resolve(&.{ "10.0.0.2", "443", "a-very-long-name" }, &buf6)); +} From dfafb3897c7a2a45bb63dcf9c8a1af8204ee15d8 Mon Sep 17 00:00:00 2001 From: draw me an elephant <68925779+drawmeanelephant@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:31:34 -0400 Subject: [PATCH 2/2] TLS13-C11: regenerate the gate-fleet inventory for live-tls13 The GF1 registration guard requires a new spec to ship with a regenerated report; CI caught it, correctly. Ran tools/inventory-gates.sh; --check is green again (226 specs, 8 declared exemptions, 2 excused violations unchanged). The spec itself passed the shape guards on the first attempt -- spec-order reported OK, meaning no ungated echo-ended run and no file op racing an exec. --- docs/gate-fleet-inventory.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/gate-fleet-inventory.md b/docs/gate-fleet-inventory.md index 23cba99f..59c50650 100644 --- a/docs/gate-fleet-inventory.md +++ b/docs/gate-fleet-inventory.md @@ -30,7 +30,7 @@ | `tools/lib/` | 2 | per-run isolation for live gates (`gate-run.sh`) | | `tools/status/` | 7 | multiagent coordination gate + claim tooling (class A) | | `tools/context/` | 2 | context snapshot helpers | -| `tools/gate/` | 240 | M40 vgate harness + specs (GF2+) | +| `tools/gate/` | 241 | M40 vgate harness + specs (GF2+) | ## Class-B fleet (discovered from the spec dir) @@ -222,6 +222,7 @@ of them with `just verify-vz`. | spec | `live-time` | 1 run / 4 assert | live-time.spec -- M22 D13 (issue #336) class-B gate: | | spec | `live-timer` | 1 run / 6 assert | live-timer.spec -- real CNTP PPI delivery through the EL1 IRQ vector. | | spec | `live-timers` | 1 run / 13 assert | live-timers.spec -- claim 7323 (Milestone 14, Card S2) class-B gate: | +| spec | `live-tls13` | 1 run / 9 assert | live-tls13.spec -- cards TLS13-C8/C11: the guest HTTPS consumer. | | spec | `live-tokens` | 12 run / 36 assert | live-tokens.spec -- M37 DQ4 design tokens & cohesion | | spec | `live-transcript` | 1 run / 6 assert | live-transcript.spec -- vgate pilot (serial-only + repeat): live RX. | | spec | `live-trust-caps` | 2 run / 14 assert | live-trust-caps.spec -- M50 TS3 class-B gate (issue #1137, ADR 0024 D5/D10). |