Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion docs/gate-fleet-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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). |
Expand Down
82 changes: 82 additions & 0 deletions tools/gate/specs/live-tls13.spec
Original file line number Diff line number Diff line change
@@ -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:'
81 changes: 66 additions & 15 deletions user/src/fetchs.zig
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.

Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -69,34 +74,76 @@ 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,
});

client.handshake() catch {
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");
Expand All @@ -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");

Expand All @@ -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);
Expand Down
127 changes: 127 additions & 0 deletions user/src/lib/tls/target.zig
Original file line number Diff line number Diff line change
@@ -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));
}
Loading