From 04318030564bfbe9291534dbc4a0942dc5dbb1be Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Fri, 16 Oct 2026 10:41:29 -0500 Subject: [PATCH 1/4] tcpclient: the LAN API over TLS (8443) with certificate pinning Port 8443 is TLS only; the default port 8080 tries TLS on 8443 first and falls back to plaintext only when 8443 refuses (firmware without LAN TLS) and nothing is pinned for the pod. Other ports and the USB network adapter (10.237.0.0/16) stay plaintext. The pod's certificate SHA-256 is pinned on first use in known_pods.json next to the CLI config; a different one is a PinMismatchError. TLSCAFile verifies against given roots instead. Co-Authored-By: Claude Opus 5.5 --- internal/tcpclient/client.go | 7 +- internal/tcpclient/session.go | 2 +- internal/tcpclient/swd.go | 2 +- internal/tcpclient/tls.go | 433 +++++++++++++++++++++++++++++++++ internal/tcpclient/tls_test.go | 155 ++++++++++++ 5 files changed, 595 insertions(+), 4 deletions(-) create mode 100644 internal/tcpclient/tls.go create mode 100644 internal/tcpclient/tls_test.go diff --git a/internal/tcpclient/client.go b/internal/tcpclient/client.go index 3c8dc70..ace8fd2 100644 --- a/internal/tcpclient/client.go +++ b/internal/tcpclient/client.go @@ -1,7 +1,7 @@ // Package tcpclient is a thin client for the bench pod firmware's TCP/JSON API. // // The firmware speaks newline-delimited JSON over a TCP socket (default port -// 8080), one client at a time. Each request is a flat JSON object +// 8080, or 8443 with TLS: tls.go), one client at a time. Each request is a flat JSON object // ({"cmd":"", ...top-level params...}) and each response line is a JSON // object of the form {"status":"ok","data":} or // {"status":"error","message":"..."}. Commands that return large arrays @@ -50,6 +50,9 @@ const ( type Client struct { Addr string // host:port; callers should port-normalise first DialTimeout time.Duration // total connect budget across retries; defaults to DefaultDialTimeout when zero + TLS TLSMode // TLSDefault: DefaultTLSMode (tls.go) + + lastTLS bool // the last connection used TLS } // reply is one decoded response line from the firmware. @@ -169,7 +172,7 @@ func (c *Client) dialAndSend(ctx context.Context, req map[string]any) (net.Conn, if budget <= 0 { budget = DefaultDialTimeout } - conn, err := dialWithRetry(ctx, "tcp", addr, budget, dialAttemptTimeout, dialRetryBackoff) + conn, err := c.dial(ctx, budget) if err != nil { return nil, nil, err } diff --git a/internal/tcpclient/session.go b/internal/tcpclient/session.go index c9e12d9..47932e1 100644 --- a/internal/tcpclient/session.go +++ b/internal/tcpclient/session.go @@ -38,7 +38,7 @@ func (c *Client) Open(ctx context.Context) (*Session, error) { if budget <= 0 { budget = DefaultDialTimeout } - conn, err := dialWithRetry(ctx, "tcp", addr, budget, dialAttemptTimeout, dialRetryBackoff) + conn, err := c.dial(ctx, budget) if err != nil { return nil, err } diff --git a/internal/tcpclient/swd.go b/internal/tcpclient/swd.go index 84bc084..c48926a 100644 --- a/internal/tcpclient/swd.go +++ b/internal/tcpclient/swd.go @@ -39,7 +39,7 @@ func (c *Client) startRawMode(ctx context.Context, cmd string, swclk, swdio int, if budget <= 0 { budget = DefaultDialTimeout } - conn, err := dialWithRetry(ctx, "tcp", addr, budget, dialAttemptTimeout, dialRetryBackoff) + conn, err := c.dial(ctx, budget) if err != nil { return nil, err } diff --git a/internal/tcpclient/tls.go b/internal/tcpclient/tls.go new file mode 100644 index 0000000..983d76e --- /dev/null +++ b/internal/tcpclient/tls.go @@ -0,0 +1,433 @@ +package tcpclient + +// The LAN API over TLS (firmware 3.9.3+, docs/design/lan-tls.md in benchpod-firmware). +// +// The pod serves the same API on TCP 8443 with TLS 1.2 and its own certificate (self-signed on +// first boot, or one the user installed). How a Client connects: +// +// - port 8443: TLS only. +// - port 8080 (the default): TLS on 8443 first; when 8443 refuses the connection (firmware +// without LAN TLS) and the client has no pin for that pod, plaintext 8080 as before. With a +// pin, a pod that stops answering TLS is an error, never a silent downgrade. +// - any other port, and the pod's USB network adapter (10.237.0.0/16, a physical link): as +// given, plaintext. +// +// Trust: the SHA-256 of the pod's certificate (DER) is pinned on first use per address in +// known_pods.json next to the CLI's config (shared with the embeddedci SDK). A different +// certificate later is a PinMismatchError. With TLSCAFile set, the certificate is verified against +// those roots and the host name instead, and nothing is pinned. + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" +) + +// TLSMode picks how a Client connects. +type TLSMode int + +const ( + TLSDefault TLSMode = iota // DefaultTLSMode + TLSAuto // TLS when the pod has it (see the package comment) + TLSOn // TLS only + TLSOff // plaintext only +) + +// ParseTLSMode reads "auto", "on" or "off". +func ParseTLSMode(s string) (TLSMode, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "auto": + return TLSAuto, nil + case "on", "true", "yes": + return TLSOn, nil + case "off", "false", "no": + return TLSOff, nil + } + return TLSDefault, fmt.Errorf("tls: %q is not auto, on or off", s) +} + +const ( + PlainPort = "8080" + TLSPort = "8443" + // tlsHandshakeTimeout bounds the handshake: the pod does ECDHE + ECDSA in software. + tlsHandshakeTimeout = 10 * time.Second +) + +// Process-wide settings, set once by the CLI from its flags and environment. +var ( + DefaultTLSMode = TLSAuto + TLSCAFile string // PEM roots; when set, verify chain and host name instead of pinning + PinStorePath string // "" = DefaultPinStorePath() +) + +// usbLink: the pod's USB network adapter subnet (firmware usb_dhcpd.c). +var usbLink = &net.IPNet{IP: net.IPv4(10, 237, 0, 0), Mask: net.CIDRMask(16, 32)} + +// IsUSBLinkAddr reports whether host is on the pod's USB network adapter. +func IsUSBLinkAddr(host string) bool { + ip := net.ParseIP(host) + return ip != nil && usbLink.Contains(ip) +} + +// TLSAddr is the address a TLS connection to addr's pod goes to: host:8443 for the default +// port (or none), else addr as given. It is also the key of the pod's pin. +func TLSAddr(addr string) string { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return net.JoinHostPort(addr, TLSPort) + } + if port == PlainPort { + return net.JoinHostPort(host, TLSPort) + } + return addr +} + +// noTLS remembers pods that refused 8443 in this process, so each command does not probe again. +var noTLS sync.Map // addr -> true + +// dialPlan is how dial reaches addr. +type dialPlan struct { + tlsAddr string // "" = no TLS attempt + plainAddr string // "" = no plaintext + fallback bool // plaintext only when TLS is refused and nothing is pinned +} + +func planDial(addr string, mode TLSMode) dialPlan { + if mode == TLSDefault { + mode = DefaultTLSMode + } + host, port, err := net.SplitHostPort(addr) + if err != nil { + return dialPlan{plainAddr: addr} + } + switch { + case mode == TLSOff: + return dialPlan{plainAddr: addr} + case port == TLSPort: + return dialPlan{tlsAddr: addr} + case mode == TLSOn && port == PlainPort: + return dialPlan{tlsAddr: net.JoinHostPort(host, TLSPort)} + case mode == TLSOn: + return dialPlan{tlsAddr: addr} + case port != PlainPort || IsUSBLinkAddr(host): + return dialPlan{plainAddr: addr} + } + return dialPlan{tlsAddr: net.JoinHostPort(host, TLSPort), plainAddr: addr, fallback: true} +} + +// dial connects to c.Addr as the TLS mode says, within budget. The returned connection is a +// *tls.Conn or a *net.TCPConn; c.lastTLS records which. +func (c *Client) dial(ctx context.Context, budget time.Duration) (net.Conn, error) { + p := planDial(strings.TrimSpace(c.Addr), c.TLS) + if p.fallback { + if _, known := noTLS.Load(p.plainAddr); known && !pinned(p.tlsAddr) { + p.tlsAddr = "" + } + } + if p.tlsAddr == "" { + c.lastTLS = false + return dialWithRetry(ctx, "tcp", p.plainAddr, budget, dialAttemptTimeout, dialRetryBackoff) + } + if p.fallback && !pinned(p.tlsAddr) { + // One quick probe: a pod without LAN TLS answers 8443 with a reset. + raw, err := dialWithRetry(ctx, "tcp", p.tlsAddr, dialAttemptTimeout, dialAttemptTimeout, dialRetryBackoff) + if err != nil { + if errors.Is(err, syscall.ECONNREFUSED) { + noTLS.Store(p.plainAddr, true) + } + c.lastTLS = false + return dialWithRetry(ctx, "tcp", p.plainAddr, budget, dialAttemptTimeout, dialRetryBackoff) + } + return c.handshake(ctx, raw, p.tlsAddr) + } + raw, err := dialWithRetry(ctx, "tcp", p.tlsAddr, budget, dialAttemptTimeout, dialRetryBackoff) + if err != nil { + return nil, err + } + return c.handshake(ctx, raw, p.tlsAddr) +} + +func (c *Client) handshake(ctx context.Context, raw net.Conn, addr string) (net.Conn, error) { + if tc, ok := raw.(*net.TCPConn); ok { + _ = tc.SetNoDelay(true) + } + cfg, err := tlsConfig(addr, true) + if err != nil { + raw.Close() + return nil, err + } + conn := tls.Client(raw, cfg) + hctx, cancel := context.WithTimeout(ctx, tlsHandshakeTimeout) + defer cancel() + if err := conn.HandshakeContext(hctx); err != nil { + raw.Close() + var pm *PinMismatchError + if errors.As(err, &pm) { + return nil, pm + } + return nil, &dialError{addr: addr, err: fmt.Errorf("TLS handshake: %w", err)} + } + c.lastTLS = true + return conn, nil +} + +// LastDialTLS reports whether the Client's most recent connection used TLS. +func (c *Client) LastDialTLS() bool { return c.lastTLS } + +// tlsConfig is the client configuration for addr (host:8443). pin: check (and on first use +// record) the pin; false accepts any certificate (PeerCertificate, for showing and trusting). +func tlsConfig(addr string, pin bool) (*tls.Config, error) { + host, _, _ := net.SplitHostPort(addr) + // No session cache: the pod does not resume sessions, and a resumed one would skip the + // certificate the pin checks. + cfg := &tls.Config{MinVersion: tls.VersionTLS12} + if net.ParseIP(host) == nil { + cfg.ServerName = host + } + if TLSCAFile != "" && pin { + pemData, err := os.ReadFile(TLSCAFile) + if err != nil { + return nil, fmt.Errorf("TLS CA file: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemData) { + return nil, fmt.Errorf("TLS CA file %s: no PEM certificates", TLSCAFile) + } + cfg.RootCAs = pool + cfg.ServerName = host // an IP is checked against the certificate's IP SANs + return cfg, nil + } + cfg.InsecureSkipVerify = true // the pin below is the check + if pin { + cfg.VerifyConnection = func(cs tls.ConnectionState) error { + if len(cs.PeerCertificates) == 0 { + return errors.New("the pod sent no certificate") + } + return checkPin(addr, cs.PeerCertificates[0]) + } + } + return cfg, nil +} + +// Fingerprint is the lowercase hex SHA-256 of a certificate's DER, the value the pod reports +// as lan_tls sha256. +func Fingerprint(cert *x509.Certificate) string { + sum := sha256.Sum256(cert.Raw) + return hex.EncodeToString(sum[:]) +} + +// PeerCertificate connects to addr's TLS port and returns the pod's certificate without +// checking or recording a pin. +func PeerCertificate(ctx context.Context, addr string) (*x509.Certificate, error) { + taddr := TLSAddr(addr) + raw, err := dialWithRetry(ctx, "tcp", taddr, DefaultDialTimeout, dialAttemptTimeout, dialRetryBackoff) + if err != nil { + return nil, err + } + defer raw.Close() + cfg, _ := tlsConfig(taddr, false) + conn := tls.Client(raw, cfg) + hctx, cancel := context.WithTimeout(ctx, tlsHandshakeTimeout) + defer cancel() + if err := conn.HandshakeContext(hctx); err != nil { + return nil, &dialError{addr: taddr, err: fmt.Errorf("TLS handshake: %w", err)} + } + defer conn.Close() + certs := conn.ConnectionState().PeerCertificates + if len(certs) == 0 { + return nil, errors.New("the pod sent no certificate") + } + return certs[0], nil +} + +// ── pins ──────────────────────────────────────────────────────────────────── + +// PinMismatchError: the pod presented a certificate other than the pinned one. +type PinMismatchError struct { + Addr string // host:8443 + Pinned string + Got string +} + +func (e *PinMismatchError) Error() string { + host, _, _ := net.SplitHostPort(e.Addr) + return fmt.Sprintf("the pod at %s presented TLS certificate sha256 %s, but %s is pinned for it. "+ + "If its certificate was replaced or regenerated, trust the new one with "+ + "`benchpod lan-tls trust --connection %s`; otherwise something may be intercepting the connection", + e.Addr, short(e.Got), short(e.Pinned), host) +} + +func short(fp string) string { + if len(fp) > 16 { + return fp[:16] + "..." + } + return fp +} + +// Pin is one known pod. +type Pin struct { + SHA256 string `json:"sha256"` + Subject string `json:"subject,omitempty"` + Added string `json:"added,omitempty"` +} + +type pinFile struct { + Version int `json:"version"` + Pods map[string]Pin `json:"pods"` +} + +var pinMu sync.Mutex + +// DefaultPinStorePath: known_pods.json in the CLI's config directory ($XDG_CONFIG_HOME or +// ~/.config, then benchpod-cli/). +func DefaultPinStorePath() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "benchpod-cli", "known_pods.json"), nil + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", fmt.Errorf("could not resolve home directory: %w", err) + } + return filepath.Join(home, ".config", "benchpod-cli", "known_pods.json"), nil +} + +func pinPath() (string, error) { + if PinStorePath != "" { + return PinStorePath, nil + } + return DefaultPinStorePath() +} + +func loadPins() (*pinFile, string, error) { + path, err := pinPath() + if err != nil { + return nil, "", err + } + f := &pinFile{Version: 1, Pods: map[string]Pin{}} + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return f, path, nil + } + if err != nil { + return nil, path, err + } + if err := json.Unmarshal(data, f); err != nil { + return nil, path, fmt.Errorf("%s: %w", path, err) + } + if f.Pods == nil { + f.Pods = map[string]Pin{} + } + return f, path, nil +} + +func savePins(f *pinFile, path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(f, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".known_pods-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(append(data, '\n')); err != nil { + tmp.Close() + return err + } + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmp.Name(), path) +} + +// GetPin returns the pin for addr (any port; the TLS address is the key), if there is one. +func GetPin(addr string) (Pin, bool) { + pinMu.Lock() + defer pinMu.Unlock() + f, _, err := loadPins() + if err != nil { + return Pin{}, false + } + p, ok := f.Pods[TLSAddr(addr)] + return p, ok +} + +func pinned(tlsAddr string) bool { + _, ok := GetPin(tlsAddr) + return ok +} + +// SetPin records cert as the trusted certificate for addr's pod. +func SetPin(addr string, cert *x509.Certificate) error { + return SetPinFingerprint(addr, Fingerprint(cert), cert.Subject.String()) +} + +// SetPinFingerprint records a fingerprint (lowercase hex) for addr's pod. +func SetPinFingerprint(addr, sha, subject string) error { + pinMu.Lock() + defer pinMu.Unlock() + f, path, err := loadPins() + if err != nil { + return err + } + f.Pods[TLSAddr(addr)] = Pin{SHA256: strings.ToLower(sha), Subject: subject, Added: time.Now().UTC().Format(time.RFC3339)} + return savePins(f, path) +} + +// DeletePin forgets addr's pod. false when there was no pin. +func DeletePin(addr string) (bool, error) { + pinMu.Lock() + defer pinMu.Unlock() + f, path, err := loadPins() + if err != nil { + return false, err + } + key := TLSAddr(addr) + if _, ok := f.Pods[key]; !ok { + return false, nil + } + delete(f.Pods, key) + return true, savePins(f, path) +} + +// checkPin: no pin yet records this certificate (trust on first use); a pin must match. +func checkPin(addr string, cert *x509.Certificate) error { + got := Fingerprint(cert) + pinMu.Lock() + defer pinMu.Unlock() + f, path, err := loadPins() + if err != nil { + return fmt.Errorf("read the TLS pins: %w", err) + } + key := TLSAddr(addr) + if p, ok := f.Pods[key]; ok { + if !strings.EqualFold(p.SHA256, got) { + return &PinMismatchError{Addr: key, Pinned: p.SHA256, Got: got} + } + return nil + } + f.Pods[key] = Pin{SHA256: got, Subject: cert.Subject.String(), Added: time.Now().UTC().Format(time.RFC3339)} + if err := savePins(f, path); err != nil { + return fmt.Errorf("save the TLS pin: %w", err) + } + return nil +} diff --git a/internal/tcpclient/tls_test.go b/internal/tcpclient/tls_test.go new file mode 100644 index 0000000..3702516 --- /dev/null +++ b/internal/tcpclient/tls_test.go @@ -0,0 +1,155 @@ +package tcpclient + +import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "math/big" + "net" + "path/filepath" + "testing" + "time" +) + +func TestPlanDial(t *testing.T) { + cases := []struct { + addr string + mode TLSMode + want dialPlan + label string + }{ + {"192.168.1.5:8080", TLSAuto, dialPlan{tlsAddr: "192.168.1.5:8443", plainAddr: "192.168.1.5:8080", fallback: true}, "default port: TLS first"}, + {"192.168.1.5:8443", TLSAuto, dialPlan{tlsAddr: "192.168.1.5:8443"}, "8443: TLS only"}, + {"192.168.1.5:9000", TLSAuto, dialPlan{plainAddr: "192.168.1.5:9000"}, "other port: plaintext"}, + {"10.237.8.85:8080", TLSAuto, dialPlan{plainAddr: "10.237.8.85:8080"}, "USB link: plaintext"}, + {"192.168.1.5:8080", TLSOff, dialPlan{plainAddr: "192.168.1.5:8080"}, "off"}, + {"192.168.1.5:8443", TLSOff, dialPlan{plainAddr: "192.168.1.5:8443"}, "off wins over the port"}, + {"192.168.1.5:8080", TLSOn, dialPlan{tlsAddr: "192.168.1.5:8443"}, "on: 8080 means the pod"}, + {"10.237.8.85:8080", TLSOn, dialPlan{tlsAddr: "10.237.8.85:8443"}, "on over USB"}, + {"pod.local:9443", TLSOn, dialPlan{tlsAddr: "pod.local:9443"}, "on, other port as given"}, + } + for _, c := range cases { + if got := planDial(c.addr, c.mode); got != c.want { + t.Errorf("%s: planDial(%q) = %+v, want %+v", c.label, c.addr, got, c.want) + } + } +} + +// tlsPod is a TLS JSON server that answers every line with {"status":"ok","data":"pong"}. +func tlsPod(t *testing.T) (addr string, cert *x509.Certificate, setCert func()) { + t.Helper() + mk := func() tls.Certificate { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: "benchpod-test"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), DNSNames: []string{"benchpod-test.local"}} + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} + } + cur := mk() + cfg := &tls.Config{GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return &cur, nil }} + ln, err := tls.Listen("tcp", "127.0.0.1:0", cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + r := bufio.NewReader(c) + for { + if _, err := r.ReadString('\n'); err != nil { + return + } + c.Write([]byte(`{"status":"ok","data":"pong"}` + "\n")) + } + }(c) + } + }() + leaf, _ := x509.ParseCertificate(cur.Certificate[0]) + return ln.Addr().String(), leaf, func() { cur = mk() } +} + +func TestTLSPinOnFirstUseThenMismatch(t *testing.T) { + PinStorePath = filepath.Join(t.TempDir(), "known_pods.json") + defer func() { PinStorePath = "" }() + addr, cert, rotate := tlsPod(t) + ctx := context.Background() + c := &Client{Addr: addr, TLS: TLSOn, DialTimeout: 2 * time.Second} + + if _, err := c.Command(ctx, map[string]any{"cmd": "ping"}); err != nil { + t.Fatalf("first TLS command: %v", err) + } + if !c.LastDialTLS() { + t.Fatal("LastDialTLS false after a TLS command") + } + p, ok := GetPin(addr) + if !ok || p.SHA256 != Fingerprint(cert) { + t.Fatalf("pin after first use = %+v, %v; want %s", p, ok, Fingerprint(cert)) + } + // Same certificate: fine. The key is the TLS address, so the 8080 form finds it too. + if _, err := c.Command(ctx, map[string]any{"cmd": "ping"}); err != nil { + t.Fatalf("second TLS command: %v", err) + } + + rotate() + _, err := c.Command(ctx, map[string]any{"cmd": "ping"}) + var pm *PinMismatchError + if !errors.As(err, &pm) { + t.Fatalf("new certificate: err = %v, want a PinMismatchError", err) + } + if pm.Pinned != Fingerprint(cert) { + t.Errorf("mismatch names pinned %s, want %s", pm.Pinned, Fingerprint(cert)) + } + + // Trusting the new one (what `benchpod lan-tls trust` does) makes it work again. + got, err := PeerCertificate(ctx, addr) + if err != nil || Fingerprint(got) != pm.Got { + t.Fatalf("PeerCertificate = %v; want the new certificate %s", err, pm.Got) + } + if err := SetPin(addr, got); err != nil { + t.Fatal(err) + } + if _, err := c.Command(ctx, map[string]any{"cmd": "ping"}); err != nil { + t.Fatalf("after trusting the new certificate: %v", err) + } + if ok, err := DeletePin(addr); !ok || err != nil { + t.Fatalf("DeletePin = %v, %v", ok, err) + } + if _, ok := GetPin(addr); ok { + t.Fatal("pin still there after DeletePin") + } +} + +func TestAutoFallsBackOnlyWithoutPin(t *testing.T) { + PinStorePath = filepath.Join(t.TempDir(), "known_pods.json") + defer func() { PinStorePath = "" }() + // A plaintext pod on 127.0.0.1:; nothing on 8443 there. planDial's fallback only + // exists for port 8080, so drive the fallback rule directly. + p := planDial("127.0.0.1:8080", TLSAuto) + if !p.fallback || p.tlsAddr != "127.0.0.1:8443" { + t.Fatalf("plan = %+v", p) + } + if pinned(p.tlsAddr) { + t.Fatal("pinned in an empty store") + } + if err := SetPinFingerprint("127.0.0.1:8080", "ab", ""); err != nil { + t.Fatal(err) + } + if !pinned(p.tlsAddr) { + t.Fatal("a pin for the pod is keyed by its TLS address") + } +} From 477a7c52fcff8772dfa943e7a669b9ba52dfbe58 Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Fri, 16 Oct 2026 10:44:21 -0500 Subject: [PATCH 2/4] lan-tls commands, --tls and --tls-ca benchpod lan-tls [show [--pem]] | https-only on|off | regenerate | set-cert --cert --key | trust [--sha256] | forget, for the pod's LAN API over TLS (firmware 3.9.3+). set-cert checks the pair here, uploads it as OTA target "tls" and updates the pin; regenerate updates the pin from the reply. --tls auto|on|off (BENCHPOD_TLS) and --tls-ca (BENCHPOD_TLS_CA) pick the transport and the trust. README: Connection, LAN TLS section, reference. Co-Authored-By: Claude Opus 5.5 --- README.md | 101 ++++++++ cmd/benchpod-cli/cloudcfg.go | 8 +- cmd/benchpod-cli/lantls.go | 397 ++++++++++++++++++++++++++++++++ cmd/benchpod-cli/lantls_test.go | 57 +++++ cmd/benchpod-cli/root.go | 18 +- internal/tcpclient/session.go | 7 + 6 files changed, 585 insertions(+), 3 deletions(-) create mode 100644 cmd/benchpod-cli/lantls.go create mode 100644 cmd/benchpod-cli/lantls_test.go diff --git a/README.md b/README.md index d35d200..560eaec 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,15 @@ reach the pod; the transport is inferred from its value: | `embeddedci:` | Through embeddedci.com to the pod with that name on your account (see below). | | *(omitted)* | the default saved by `benchpod discover --save`, `benchpod setup` or `benchpod set-connection`. | +**TLS.** Pods with firmware 3.9.3 or later also serve the API on port 8443 with +TLS. For a network address on the default port (`192.168.1.5`, `:8080`, a pod +name) `benchpod` connects to 8443 first and falls back to plaintext 8080 only when +the pod has no TLS port and has never been pinned. The pod's certificate is +pinned the first time (trust on first use) in `known_pods.json` next to the +config; a different certificate later stops the command with both fingerprints. +`:8443` means TLS only, any other port and the USB network adapter mean +plaintext, and `--tls on|off` overrides. See [LAN TLS](#lan-tls-encrypted-lan-api). + **Pod names.** A value shaped like a pod's name, `benchpod-a1b2c3` or just `a1b2c3` (optionally with `.local`), is looked up the way `discover` finds pods: an mDNS browse of the LAN for up to 3 seconds, which ends as soon as the pod answers. @@ -412,6 +421,40 @@ LAN policy of benchpod-baea06: locked (read and instrument control only on the L - Firmware without these commands gets "this firmware has no LAN policy; update the pod's firmware". +#### LAN TLS: encrypted LAN API + +The pod's API on port 8443 is the same as on 8080, over TLS (firmware 3.9.3+). The +pod makes its own key and self-signed certificate on first boot; `benchpod` +pins it on first use. `benchpod lan-tls` shows it: + +``` +$ benchpod lan-tls --connection 192.168.1.220 +LAN TLS of 192.168.1.220:8080 (connected over TLS) + port 8443 + https-only off + sessions 1 + certificate CN=benchpod-baea06 (generated, EC P-256), valid to 9999-12-31 + names benchpod-baea06, benchpod-baea06.local + sha256 3f1c... + pinned yes, for 192.168.1.220:8443 +``` + +- `benchpod lan-tls https-only on` turns plaintext 8080 off on Ethernet and Wi-Fi + (the pod keeps it on its USB link). Use it when the LAN path is shared, for + example through a Wi-Fi bridge. +- `benchpod lan-tls set-cert --cert pod.pem --key pod.key` installs your own + certificate (with its chain) and unencrypted key; `regenerate` makes a new + self-signed one. Both update this computer's pin. +- After a change made elsewhere, `benchpod lan-tls trust` pins the new certificate + (`--sha256` makes it check the value first); `forget` drops the pin. +- `--tls-ca ca.pem` verifies a certificate signed by your own CA (and the host + name) instead of pinning. +- The pod refuses `https-only off`, `regenerate` and `set-cert` over plaintext + Ethernet or Wi-Fi: use TLS (the default), `--connection usb` or + `--connection embeddedci:`. +- `benchpod lan-tls show --pem > pod.pem` saves the certificate, for curl or socat + (VISA raw sockets have no TLS; see the firmware API docs, LAN TLS). + #### Device identity: show and wipe The pod's identity is an Ed25519 key in its own flash sector; its name @@ -628,6 +671,8 @@ and `token.json` for the embeddedci.com session. | `--config-file` | `~/.config/benchpod-cli/config.json` | The CLI's config file, where `set-connection` saves the default. | | `--output-filename` | (stdout) | Write command output to this file instead of stdout. | | `--timeout` | `0` (per-command default) | Overall command deadline; `0` uses each command's own default. | +| `--tls` | `auto` | TLS to the pod's LAN API: `auto` (8443 when the pod has it, pinned on first use), `on` (TLS only) or `off` (plaintext 8080). Also `BENCHPOD_TLS`. See [Connection](#connection). | +| `--tls-ca` | (none) | PEM file of CA certificates: verify the pod's certificate and name against them instead of pinning. Also `BENCHPOD_TLS_CA`. | | `--no-firmware-report` | `false` | Do not report firmware and blob updates to embeddedci.com (also `BENCHPOD_NO_FIRMWARE_REPORT=1`); see [Firmware-update reports](#firmware-update-reports). | ### Firmware-update reports @@ -1228,6 +1273,62 @@ Same as `benchpod lan-policy`. Set the LAN policy (cloud with an owner or admin role, or USB). +### `benchpod lan-tls` + +Show the pod's LAN TLS certificate, its SHA-256, the HTTPS-only setting and +whether this computer has it pinned (firmware 3.9.3+). See +[LAN TLS](#lan-tls-encrypted-lan-api). Over the network, USB or embeddedci.com. + +| Flag | Default | Purpose | +|---------|---------|------------------------------------------------------------------| +| `--pem` | off | Print the certificate the pod presents on 8443, PEM, instead. | + +### `benchpod lan-tls show` + +Same as `benchpod lan-tls`. + +| Flag | Default | Purpose | +|---------|---------|-------------------------------------------------------------------------------| +| `--pem` | off | Print the certificate the pod presents on 8443, PEM (for `curl --cacert`, socat `cafile=`). | + +### `benchpod lan-tls https-only ` + +`on`: the pod refuses plaintext 8080 on Ethernet and Wi-Fi and answers only on +8443 there; its USB link keeps 8080. `off` needs a TLS, USB or embeddedci.com +connection. Stored on the pod. + +### `benchpod lan-tls regenerate` + +A new key and self-signed certificate on the pod; this computer's pin follows. +Needs a TLS, USB or embeddedci.com connection. + +### `benchpod lan-tls set-cert` + +Install your own certificate and key. Checks them here first (they must match, +the key must not be encrypted, at most 7.6 KB together), uploads them over the +network (TLS or the USB network adapter), checks the pod reports the new +certificate and updates the pin. + +| Flag | Default | Purpose | +|----------|---------|----------------------------------------------------------------| +| `--cert` | (none) | PEM certificate file, optionally followed by its chain. Required. | +| `--key` | (none) | PEM private key file (EC P-256/P-384 or RSA), not encrypted. Required. | + +### `benchpod lan-tls trust` + +Pin the certificate the pod presents now, after a change made elsewhere (the +console, the web app, another computer). Prints its SHA-256: compare it with the +pod's console `lan-tls` or the web app's pod Settings. + +| Flag | Default | Purpose | +|------------|---------|-----------------------------------------------------------| +| `--sha256` | (none) | Only trust a certificate with this SHA-256. | + +### `benchpod lan-tls forget` + +Drop this computer's pin for the pod; the next TLS connection pins what it +presents. + ### `benchpod usb-network [show|on|off]` Show the pod's USB network setting, or turn its USB network adapter on or off diff --git a/cmd/benchpod-cli/cloudcfg.go b/cmd/benchpod-cli/cloudcfg.go index fe7852a..b16eca4 100644 --- a/cmd/benchpod-cli/cloudcfg.go +++ b/cmd/benchpod-cli/cloudcfg.go @@ -597,9 +597,15 @@ func cloudCALAN(g *globalFlags, addr, action string, upload []byte, warn io.Writ // uploadCALAN stages the PEM as OTA target "ca", has the pod verify it, and commits it. func uploadCALAN(ctx context.Context, lan lanSession, data []byte) error { + return uploadOTALAN(ctx, lan, data, "ca") +} + +// uploadOTALAN stages a configuration upload as an OTA target ("ca", "tls"), has the pod verify +// it, and commits it. +func uploadOTALAN(ctx context.Context, lan lanSession, data []byte, target string) error { sum := sha256.Sum256(data) if err := lan.cmd(ctx, map[string]any{"cmd": "ota_begin", "size": len(data), - "sha256": hex.EncodeToString(sum[:]), "target": "ca"}, nil); err != nil { + "sha256": hex.EncodeToString(sum[:]), "target": target}, nil); err != nil { return err } for off := 0; off < len(data); off += caLANChunk { diff --git a/cmd/benchpod-cli/lantls.go b/cmd/benchpod-cli/lantls.go new file mode 100644 index 0000000..65d53a3 --- /dev/null +++ b/cmd/benchpod-cli/lantls.go @@ -0,0 +1,397 @@ +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net" + "os" + "strings" + "time" + + "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" + "github.com/spf13/cobra" +) + +// ── lan-tls ───────────────────────────────────────────────────────────────── +// +// The pod's LAN API over TLS (firmware 3.9.3+, benchpod-firmware docs/design/lan-tls.md): the +// certificate on port 8443 and the HTTPS-only setting. The client side (TLS by default, pinning) +// is internal/tcpclient/tls.go. +// +// The pod takes changes (https-only off, regenerate, set-cert) over TLS, over USB and from the +// cloud, never over plaintext Ethernet or Wi-Fi. + +const ( + lanTLSMissing = "this pod's firmware has no LAN TLS; update its firmware (3.9.3 or later)" + lanTLSPlainHint = "the pod takes this only over TLS, USB or embeddedci.com, not over plaintext; connect with --tls on, --connection usb or --connection embeddedci:" + lanTLSMaxUpload = 7664 // the pod's limit on the certificate + key bundle (LAN_TLS_UPLOAD_MAX) +) + +// lanTLSReply is the data of the pod's lan_tls reply. +type lanTLSReply struct { + Port int `json:"port"` + HTTPSOnly bool `json:"https_only"` + Sessions int `json:"sessions"` + Origin string `json:"origin"` + Key string `json:"key"` + Subject string `json:"subject"` + SAN []string `json:"san"` + NotAfter string `json:"not_after"` + SHA256 string `json:"sha256"` + Error string `json:"error"` +} + +func newLanTLSCmd(g *globalFlags) *cobra.Command { + var pemOut bool + run := func(fn func(out, warn io.Writer) error) error { + out, closeOut, err := resolveOutput(g.outputFilename) + if err != nil { + return fmt.Errorf("lan-tls: open output: %w", err) + } + defer closeOut() + return fn(out, os.Stderr) + } + show := func(_ *cobra.Command, _ []string) error { + return run(func(out, warn io.Writer) error { + if pemOut { + return runLanTLSPEM(g, out) + } + return runLanTLS(g, nil, out, warn) + }) + } + root := &cobra.Command{ + Use: "lan-tls [show]", + Short: "Show or change the pod's LAN TLS certificate and the HTTPS-only setting", + Long: "The pod serves its API on port 8443 with TLS as well as on plaintext 8080\n" + + "(firmware 3.9.3+). benchpod connects with TLS when the pod has it and pins the\n" + + "pod's certificate the first time (trust on first use, in known_pods.json next to\n" + + "the CLI config). --tls off forces plaintext; --tls-ca verifies a certificate\n" + + "signed by your own CA instead of pinning.\n\n" + + " show the certificate, its SHA-256 and whether it is pinned here\n" + + " https-only on: the pod answers plaintext 8080 only on its USB link\n" + + " regenerate a new key and self-signed certificate\n" + + " set-cert install your own certificate and key\n" + + " trust pin the certificate the pod presents now\n" + + " forget drop the pin for this pod\n\n" + + "Turning HTTPS-only off, regenerating and set-cert need a TLS, USB or\n" + + "embeddedci.com connection: the pod refuses them over plaintext Ethernet or Wi-Fi.", + Args: cobra.NoArgs, + RunE: show, + } + showCmd := &cobra.Command{Use: "show", Short: "Show the pod's LAN TLS certificate and settings", Args: cobra.NoArgs, RunE: show} + showCmd.Flags().BoolVar(&pemOut, "pem", false, "print the certificate the pod presents, PEM (for socat cafile=, curl --cacert)") + root.Flags().BoolVar(&pemOut, "pem", false, "print the certificate the pod presents, PEM") + + var certFile, keyFile, expect string + setCert := &cobra.Command{ + Use: "set-cert --cert --key ", + Short: "Install your own certificate and private key for port 8443", + Long: "Install a certificate (optionally followed by its chain) and its unencrypted\n" + + "private key (EC P-256/P-384 or RSA 2048-4096). The pod checks both and that the\n" + + "key matches before it stores them; new TLS connections use it. Over the network\n" + + "only (TLS or the USB network adapter). This computer's pin is updated.", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return run(func(out, warn io.Writer) error { return runLanTLSSetCert(g, certFile, keyFile, out, warn) }) + }, + } + setCert.Flags().StringVar(&certFile, "cert", "", "PEM certificate file (with its chain, if any)") + setCert.Flags().StringVar(&keyFile, "key", "", "PEM private key file, not encrypted") + _ = setCert.MarkFlagRequired("cert") + _ = setCert.MarkFlagRequired("key") + + trust := &cobra.Command{ + Use: "trust", + Short: "Pin the certificate the pod presents now (after a deliberate change)", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return run(func(out, _ io.Writer) error { return runLanTLSTrust(g, expect, out) }) + }, + } + trust.Flags().StringVar(&expect, "sha256", "", "only trust a certificate with this SHA-256 (from the pod's console `lan-tls` or the web app)") + + root.AddCommand(showCmd, &cobra.Command{ + Use: "https-only ", + Short: "Turn plaintext 8080 off (on) or back on (off) for Ethernet and Wi-Fi", + Args: cobra.ExactArgs(1), + ValidArgs: []string{"on", "off"}, + RunE: func(_ *cobra.Command, args []string) error { + v := strings.ToLower(strings.TrimSpace(args[0])) + if v != "on" && v != "off" { + return fmt.Errorf("lan-tls https-only: %q is not on or off", args[0]) + } + return run(func(out, warn io.Writer) error { + return runLanTLS(g, map[string]any{"https_only": v == "on"}, out, warn) + }) + }, + }, &cobra.Command{ + Use: "regenerate", + Short: "Make a new key and self-signed certificate on the pod", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return run(func(out, warn io.Writer) error { + return runLanTLS(g, map[string]any{"regenerate": true}, out, warn) + }) + }, + }, setCert, trust, &cobra.Command{ + Use: "forget", + Short: "Drop this computer's pin for the pod", + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return run(func(out, _ io.Writer) error { return runLanTLSForget(g, out) }) + }, + }) + return root +} + +// lanTLSError turns a refusal into one clear line. +func lanTLSError(what string, err error) error { + if pe, ok := tcpclient.AsPodError(err); ok { + r := pe.Reason + switch { + case strings.Contains(r, "unknown command") || strings.Contains(r, "unknown cmd"): + return errors.New(lanTLSMissing) + case strings.Contains(r, "change it over TLS"): + return fmt.Errorf("%s: %s", what, lanTLSPlainHint) + } + } + return fmt.Errorf("%s: %w", what, err) +} + +// runLanTLS sends lan_tls with the given change (nil: show) and prints the result. +func runLanTLS(g *globalFlags, change map[string]any, out, warn io.Writer) error { + ctx, cancel, t, err := g.podClient("lan-tls", 30*time.Second) + if err != nil { + return err + } + defer cancel() + req := map[string]any{"cmd": "lan_tls"} + for k, v := range change { + req[k] = v + } + raw, err := t.client.Command(ctx, req) + if err != nil { + return lanTLSError("lan-tls", err) + } + var rep lanTLSReply + if err := json.Unmarshal(raw, &rep); err != nil { + return fmt.Errorf("lan-tls: unexpected reply: %s", strings.TrimSpace(string(raw))) + } + addr := "" + if !t.spec.IsCloud() { + addr = t.spec.Addr + } + // This computer made the change: pin the new certificate for the address it used. + if change["regenerate"] == true && addr != "" && rep.SHA256 != "" && !tcpclient.IsUSBLinkAddr(hostOf(addr)) { + if err := tcpclient.SetPinFingerprint(addr, rep.SHA256, rep.Subject); err != nil { + fmt.Fprintf(warn, "Could not update the pin: %v\n", err) + } + } + printLanTLS(out, t.label, addr, rep, lastTLS(t)) + if hs, ok := change["https_only"].(bool); ok && hs { + fmt.Fprintln(warn, "Plaintext port 8080 now answers only on the pod's USB link. Tools that need it there:\n"+ + "VISA raw sockets and older benchpod/SDK releases (see the pod's API docs, LAN TLS).") + } + return nil +} + +func hostOf(addr string) string { + h, _, err := net.SplitHostPort(addr) + if err != nil { + return addr + } + return h +} + +func lastTLS(t *podTarget) string { + if c, ok := t.client.(*tcpclient.Client); ok { + if c.LastDialTLS() { + return "TLS" + } + return "plaintext" + } + return "embeddedci.com" +} + +func printLanTLS(out io.Writer, label, addr string, r lanTLSReply, via string) { + onOff := map[bool]string{true: "on", false: "off"} + fmt.Fprintf(out, "LAN TLS of %s (connected over %s)\n", label, via) + fmt.Fprintf(out, " port %d\n", r.Port) + fmt.Fprintf(out, " https-only %s\n", onOff[r.HTTPSOnly]) + fmt.Fprintf(out, " sessions %d\n", r.Sessions) + if r.SHA256 == "" { + fmt.Fprintf(out, " certificate none (%s)\n", valueOr(r.Error, "unknown")) + return + } + fmt.Fprintf(out, " certificate %s (%s, %s), valid to %s\n", r.Subject, r.Origin, r.Key, r.NotAfter) + if len(r.SAN) > 0 { + fmt.Fprintf(out, " names %s\n", strings.Join(r.SAN, ", ")) + } + fmt.Fprintf(out, " sha256 %s\n", r.SHA256) + if addr == "" { + return + } + switch p, ok := tcpclient.GetPin(addr); { + case tcpclient.IsUSBLinkAddr(hostOf(addr)): + fmt.Fprintln(out, " pinned not for the USB link (plaintext there)") + case !ok: + fmt.Fprintln(out, " pinned no") + case strings.EqualFold(p.SHA256, r.SHA256): + fmt.Fprintf(out, " pinned yes, for %s\n", tcpclient.TLSAddr(addr)) + default: + fmt.Fprintf(out, " pinned DIFFERENT: %s (run `benchpod lan-tls trust` if the change was yours)\n", p.SHA256) + } +} + +// lanTLSAddr is the pod's network address for the commands that talk TLS to it directly. +func lanTLSAddr(g *globalFlags, what string) (string, error) { + spec, err := g.networkTarget("lan-tls " + what) + if err != nil { + return "", err + } + if spec.IsCloud() { + return "", fmt.Errorf("lan-tls %s: needs the pod's network address (--connection
), not embeddedci.com", what) + } + return spec.Addr, nil +} + +func runLanTLSPEM(g *globalFlags, out io.Writer) error { + addr, err := lanTLSAddr(g, "show --pem") + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(20*time.Second)) + defer cancel() + cert, err := tcpclient.PeerCertificate(ctx, addr) + if err != nil { + return fmt.Errorf("lan-tls show --pem: %w", err) + } + return pem.Encode(out, &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}) +} + +func runLanTLSTrust(g *globalFlags, expect string, out io.Writer) error { + addr, err := lanTLSAddr(g, "trust") + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(20*time.Second)) + defer cancel() + cert, err := tcpclient.PeerCertificate(ctx, addr) + if err != nil { + return fmt.Errorf("lan-tls trust: %w", err) + } + fp := tcpclient.Fingerprint(cert) + if e := strings.ToLower(strings.TrimSpace(expect)); e != "" && e != fp { + return fmt.Errorf("lan-tls trust: the pod presents sha256 %s, not %s; not trusted", fp, e) + } + if err := tcpclient.SetPin(addr, cert); err != nil { + return fmt.Errorf("lan-tls trust: %w", err) + } + fmt.Fprintf(out, "Trusted %s for %s\n sha256 %s\n", cert.Subject.String(), tcpclient.TLSAddr(addr), fp) + if expect == "" { + fmt.Fprintln(out, "Compare it with the pod's console (`lan-tls`) or the web app's pod Settings.") + } + return nil +} + +func runLanTLSForget(g *globalFlags, out io.Writer) error { + addr, err := lanTLSAddr(g, "forget") + if err != nil { + return err + } + ok, err := tcpclient.DeletePin(addr) + if err != nil { + return fmt.Errorf("lan-tls forget: %w", err) + } + if !ok { + fmt.Fprintf(out, "No pin for %s\n", tcpclient.TLSAddr(addr)) + return nil + } + fmt.Fprintf(out, "Forgot the pin for %s; the next TLS connection pins what the pod presents\n", tcpclient.TLSAddr(addr)) + return nil +} + +// checkCertKey reads the two files, checks them like the pod will, and returns the bundle the +// pod takes (certificate chain, then the key) and the leaf certificate. +func checkCertKey(certFile, keyFile string) ([]byte, *x509.Certificate, error) { + certPEM, err := os.ReadFile(certFile) + if err != nil { + return nil, nil, err + } + keyPEM, err := os.ReadFile(keyFile) + if err != nil { + return nil, nil, err + } + if blk, _ := pem.Decode(keyPEM); blk == nil { + return nil, nil, fmt.Errorf("%s: not a PEM private key", keyFile) + } else if strings.Contains(blk.Type, "ENCRYPTED") || blk.Headers["Proc-Type"] != "" { + return nil, nil, fmt.Errorf("%s: the key is encrypted; the pod needs it without a passphrase", keyFile) + } + pair, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, nil, fmt.Errorf("certificate and key: %w", err) + } + leaf, err := x509.ParseCertificate(pair.Certificate[0]) + if err != nil { + return nil, nil, err + } + var bundle []byte + for _, der := range pair.Certificate { + bundle = append(bundle, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...) + } + bundle = append(bundle, keyPEM...) + if len(bundle) > lanTLSMaxUpload { + return nil, nil, fmt.Errorf("certificate chain and key are %d bytes; the pod takes at most %d", len(bundle), lanTLSMaxUpload) + } + return bundle, leaf, nil +} + +func runLanTLSSetCert(g *globalFlags, certFile, keyFile string, out, warn io.Writer) error { + bundle, leaf, err := checkCertKey(certFile, keyFile) + if err != nil { + return fmt.Errorf("lan-tls set-cert: %w", err) + } + addr, err := lanTLSAddr(g, "set-cert") + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), g.effectiveTimeout(60*time.Second)) + defer cancel() + defer installSignalHandler(ctx, cancel)() + + lan, err := openLAN(ctx, addr) + if err != nil { + return fmt.Errorf("lan-tls set-cert: %w", err) + } + defer lan.s.Close() + if err := lan.cmd(ctx, map[string]any{"cmd": "lan_tls"}, &lanTLSReply{}); err != nil { + return lanTLSError("lan-tls set-cert", err) + } + fmt.Fprintf(warn, "Uploading %d bytes to %s ...\n", len(bundle), addr) + if err := uploadOTALAN(ctx, lan, bundle, "tls"); err != nil { + if !lan.s.Broken() { + _ = lan.cmd(ctx, map[string]any{"cmd": "ota_abort"}, nil) + } + return lanTLSError("lan-tls set-cert", err) + } + var rep lanTLSReply + if err := lan.cmd(ctx, map[string]any{"cmd": "lan_tls"}, &rep); err != nil { + return lanTLSError("lan-tls set-cert", err) + } + if want := tcpclient.Fingerprint(leaf); !strings.EqualFold(rep.SHA256, want) { + return fmt.Errorf("lan-tls set-cert: the pod reports sha256 %s after the install, expected %s", rep.SHA256, want) + } + if !tcpclient.IsUSBLinkAddr(hostOf(addr)) { + if err := tcpclient.SetPin(addr, leaf); err != nil { + fmt.Fprintf(warn, "Could not update the pin: %v\n", err) + } + } + printLanTLS(out, addr, addr, rep, map[bool]string{true: "TLS", false: "plaintext"}[lan.s.TLS()]) + return nil +} diff --git a/cmd/benchpod-cli/lantls_test.go b/cmd/benchpod-cli/lantls_test.go new file mode 100644 index 0000000..4c54ec6 --- /dev/null +++ b/cmd/benchpod-cli/lantls_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func writeCertKey(t *testing.T, dir, name string) (certFile, keyFile string) { + t.Helper() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: name}, + NotBefore: time.Now(), NotAfter: time.Now().Add(time.Hour), DNSNames: []string{name + ".local"}} + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + kder, _ := x509.MarshalECPrivateKey(key) + certFile, keyFile = filepath.Join(dir, name+".pem"), filepath.Join(dir, name+".key") + os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600) + os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kder}), 0o600) + return +} + +func TestCheckCertKey(t *testing.T) { + dir := t.TempDir() + c1, k1 := writeCertKey(t, dir, "pod1") + _, k2 := writeCertKey(t, dir, "pod2") + + bundle, leaf, err := checkCertKey(c1, k1) + if err != nil { + t.Fatalf("matching pair refused: %v", err) + } + if leaf.Subject.CommonName != "pod1" { + t.Errorf("leaf = %v", leaf.Subject) + } + if !strings.Contains(string(bundle), "BEGIN CERTIFICATE") || !strings.Contains(string(bundle), "BEGIN EC PRIVATE KEY") { + t.Errorf("bundle lacks the certificate or the key:\n%s", bundle) + } + if _, _, err := checkCertKey(c1, k2); err == nil { + t.Error("a key that does not match the certificate was accepted") + } + enc := filepath.Join(dir, "enc.key") + os.WriteFile(enc, []byte("-----BEGIN ENCRYPTED PRIVATE KEY-----\nAAAA\n-----END ENCRYPTED PRIVATE KEY-----\n"), 0o600) + if _, _, err := checkCertKey(c1, enc); err == nil || !strings.Contains(err.Error(), "encrypted") { + t.Errorf("encrypted key: err = %v", err) + } +} diff --git a/cmd/benchpod-cli/root.go b/cmd/benchpod-cli/root.go index ff56676..45f2f91 100644 --- a/cmd/benchpod-cli/root.go +++ b/cmd/benchpod-cli/root.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/embeddedci-com/benchpod-cli/internal/tcpclient" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -18,6 +19,8 @@ type globalFlags struct { configFile string outputFilename string timeout time.Duration + tls string // --tls: auto | on | off (tcpclient.DefaultTLSMode) + tlsCA string // --tls-ca: verify the pod's certificate against these roots noFirmwareReport bool // --no-firmware-report: do not tell embeddedci.com about firmware updates (fwreport.go) @@ -79,6 +82,14 @@ func newRootCmd() *cobra.Command { g.configFile = viper.GetString("config-file") g.outputFilename = viper.GetString("output-filename") g.timeout = viper.GetDuration("timeout") + g.tls = viper.GetString("tls") + g.tlsCA = viper.GetString("tls-ca") + mode, err := tcpclient.ParseTLSMode(g.tls) + if err != nil { + return usageErrorf("%v", err) + } + tcpclient.DefaultTLSMode = mode + tcpclient.TLSCAFile = g.tlsCA return nil }, } @@ -89,13 +100,16 @@ func newRootCmd() *cobra.Command { pf.StringVar(&g.configFile, "config-file", "", "path to config file") pf.StringVar(&g.outputFilename, "output-filename", "", "write command output to this file instead of stdout") pf.DurationVar(&g.timeout, "timeout", 0, "overall command deadline (0 = per-command default)") + pf.StringVar(&g.tls, "tls", "auto", + "TLS to the pod's LAN API: auto (TLS on 8443 when the pod has it, pinned on first use), on (TLS only) or off (plaintext 8080)") + pf.StringVar(&g.tlsCA, "tls-ca", "", "PEM file of CA certificates: verify the pod's TLS certificate and name against them instead of pinning") pf.BoolVar(&g.noFirmwareReport, "no-firmware-report", false, "do not report firmware and blob updates to embeddedci.com (also BENCHPOD_NO_FIRMWARE_REPORT=1)") viper.SetEnvPrefix("BENCHPOD") viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) viper.AutomaticEnv() - for _, name := range []string{"connection", "config-file", "output-filename", "timeout"} { + for _, name := range []string{"connection", "config-file", "output-filename", "timeout", "tls", "tls-ca"} { _ = viper.BindPFlag(name, pf.Lookup(name)) } @@ -112,7 +126,7 @@ func newRootCmd() *cobra.Command { {"pod", "The pod:", []*cobra.Command{ newStatusCmd(g), newPingCmd(g), newLACmd(g), newIdentityCmd(g), newFlashSelfCmd(g), newInstallBlobsCmd(g), newBootselCmd(g), newDfuCmd(g), - newLanPolicyCmd(g), newSigPolicyCmd(g), newUSBNetworkCmd(g), + newLanPolicyCmd(g), newSigPolicyCmd(g), newUSBNetworkCmd(g), newLanTLSCmd(g), }}, {"target", "The target (DUT) and analog I/O:", []*cobra.Command{ newFlashCmd(g), newSPIFlashCmd(g), newGenerateCmd(g), newCaptureCmd(g), diff --git a/internal/tcpclient/session.go b/internal/tcpclient/session.go index 47932e1..afd3373 100644 --- a/internal/tcpclient/session.go +++ b/internal/tcpclient/session.go @@ -3,6 +3,7 @@ package tcpclient import ( "bufio" "context" + "crypto/tls" "encoding/json" "fmt" "net" @@ -104,5 +105,11 @@ func (s *Session) Broken() bool { return s.broken != nil } +// TLS reports whether the session's connection is a TLS one. +func (s *Session) TLS() bool { + _, ok := s.conn.(*tls.Conn) + return ok +} + // Close closes the connection. func (s *Session) Close() error { return s.conn.Close() } From a9db914e2304d96f45fd6c0111d2088983cccebf Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Fri, 16 Oct 2026 11:31:54 -0500 Subject: [PATCH 3/4] tcpclient: resume TLS sessions with the pod (session tickets) A full handshake costs the pod ~0.46 s; with the session cache later connections in the same process resume in ~35 ms (HW: 476 ms, then 44/36/32 ms). TestTLSResumesOnThePod runs against a pod when BENCHPOD_TLS_HW_ADDR is set. The rotation test's server disables tickets. Co-Authored-By: Claude Opus 5.5 --- internal/tcpclient/tls.go | 10 ++++++--- internal/tcpclient/tls_hw_test.go | 36 +++++++++++++++++++++++++++++++ internal/tcpclient/tls_test.go | 4 +++- 3 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 internal/tcpclient/tls_hw_test.go diff --git a/internal/tcpclient/tls.go b/internal/tcpclient/tls.go index 983d76e..5d19892 100644 --- a/internal/tcpclient/tls.go +++ b/internal/tcpclient/tls.go @@ -186,13 +186,17 @@ func (c *Client) handshake(ctx context.Context, raw net.Conn, addr string) (net. // LastDialTLS reports whether the Client's most recent connection used TLS. func (c *Client) LastDialTLS() bool { return c.lastTLS } +var sessionCache = tls.NewLRUClientSessionCache(32) + // tlsConfig is the client configuration for addr (host:8443). pin: check (and on first use // record) the pin; false accepts any certificate (PeerCertificate, for showing and trusting). func tlsConfig(addr string, pin bool) (*tls.Config, error) { host, _, _ := net.SplitHostPort(addr) - // No session cache: the pod does not resume sessions, and a resumed one would skip the - // certificate the pin checks. - cfg := &tls.Config{MinVersion: tls.VersionTLS12} + // Resume sessions (the pod issues TLS 1.2 tickets): a full handshake costs the pod ~460 ms of + // ECDHE + ECDSA, a resumed one ~20 ms, and every command opens a connection. A resumed + // session proves the same server as the full handshake whose certificate was pinned; + // VerifyConnection still runs on it with that certificate. + cfg := &tls.Config{MinVersion: tls.VersionTLS12, ClientSessionCache: sessionCache} if net.ParseIP(host) == nil { cfg.ServerName = host } diff --git a/internal/tcpclient/tls_hw_test.go b/internal/tcpclient/tls_hw_test.go new file mode 100644 index 0000000..5801c6c --- /dev/null +++ b/internal/tcpclient/tls_hw_test.go @@ -0,0 +1,36 @@ +package tcpclient + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// TestTLSResumesOnThePod needs a pod with LAN TLS (firmware 3.9.3+): BENCHPOD_TLS_HW_ADDR=. +// The first command does a full handshake; later ones resume with the pod's session ticket. +func TestTLSResumesOnThePod(t *testing.T) { + addr := os.Getenv("BENCHPOD_TLS_HW_ADDR") + if addr == "" { + t.Skip("BENCHPOD_TLS_HW_ADDR not set") + } + PinStorePath = filepath.Join(t.TempDir(), "known_pods.json") + defer func() { PinStorePath = "" }() + c := &Client{Addr: addr + ":8080"} + var times []time.Duration + for i := 0; i < 4; i++ { + t0 := time.Now() + if _, err := c.Command(context.Background(), map[string]any{"cmd": "ping"}); err != nil { + t.Fatal(err) + } + times = append(times, time.Since(t0)) + if !c.LastDialTLS() { + t.Fatal("not TLS") + } + } + t.Logf("ping round trips: %v", times) + if times[3] > 200*time.Millisecond { + t.Errorf("no resumption: the 4th ping took %v", times[3]) + } +} diff --git a/internal/tcpclient/tls_test.go b/internal/tcpclient/tls_test.go index 3702516..adbef6d 100644 --- a/internal/tcpclient/tls_test.go +++ b/internal/tcpclient/tls_test.go @@ -55,7 +55,9 @@ func tlsPod(t *testing.T) (addr string, cert *x509.Certificate, setCert func()) return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} } cur := mk() - cfg := &tls.Config{GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return &cur, nil }} + // No tickets: a resumed session would carry the old certificate past rotate(). + cfg := &tls.Config{GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return &cur, nil }, + SessionTicketsDisabled: true} ln, err := tls.Listen("tcp", "127.0.0.1:0", cfg) if err != nil { t.Fatal(err) From acbd8a7234b6636b58b646dc4c15b6e233aac8ed Mon Sep 17 00:00:00 2001 From: Edward Viaene Date: Fri, 16 Oct 2026 11:38:36 -0500 Subject: [PATCH 4/4] tcpclient: keep the pod's TLS session ticket between runs Each benchpod run is a new process, so every run paid the pod's full TLS handshake: 0.48 s per command against 0.01 s plaintext (HW, a1f6f2). The ticket and its session state are saved in tls_sessions.json next to the pins (0600, dropped after the pod's one-hour ticket lifetime, and whenever a pin changes); later runs resume: 0.04-0.05 s per command. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 + internal/tcpclient/tls.go | 4 +- internal/tcpclient/tls_sessions.go | 150 ++++++++++++++++++++++++ internal/tcpclient/tls_sessions_test.go | 63 ++++++++++ 4 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 internal/tcpclient/tls_sessions.go create mode 100644 internal/tcpclient/tls_sessions_test.go diff --git a/README.md b/README.md index 560eaec..7e7e37f 100644 --- a/README.md +++ b/README.md @@ -449,6 +449,9 @@ LAN TLS of 192.168.1.220:8080 (connected over TLS) (`--sha256` makes it check the value first); `forget` drops the pin. - `--tls-ca ca.pem` verifies a certificate signed by your own CA (and the host name) instead of pinning. +- A TLS handshake costs the pod about 0.5 s; `benchpod` keeps the pod's session + ticket for an hour in `tls_sessions.json` (mode 0600, next to the pins), so later + runs resume in about 40 ms. - The pod refuses `https-only off`, `regenerate` and `set-cert` over plaintext Ethernet or Wi-Fi: use TLS (the default), `--connection usb` or `--connection embeddedci:`. diff --git a/internal/tcpclient/tls.go b/internal/tcpclient/tls.go index 5d19892..8e553f7 100644 --- a/internal/tcpclient/tls.go +++ b/internal/tcpclient/tls.go @@ -186,8 +186,6 @@ func (c *Client) handshake(ctx context.Context, raw net.Conn, addr string) (net. // LastDialTLS reports whether the Client's most recent connection used TLS. func (c *Client) LastDialTLS() bool { return c.lastTLS } -var sessionCache = tls.NewLRUClientSessionCache(32) - // tlsConfig is the client configuration for addr (host:8443). pin: check (and on first use // record) the pin; false accepts any certificate (PeerCertificate, for showing and trusting). func tlsConfig(addr string, pin bool) (*tls.Config, error) { @@ -394,6 +392,7 @@ func SetPinFingerprint(addr, sha, subject string) error { return err } f.Pods[TLSAddr(addr)] = Pin{SHA256: strings.ToLower(sha), Subject: subject, Added: time.Now().UTC().Format(time.RFC3339)} + forgetSessions() return savePins(f, path) } @@ -410,6 +409,7 @@ func DeletePin(addr string) (bool, error) { return false, nil } delete(f.Pods, key) + forgetSessions() return true, savePins(f, path) } diff --git a/internal/tcpclient/tls_sessions.go b/internal/tcpclient/tls_sessions.go new file mode 100644 index 0000000..320b6b6 --- /dev/null +++ b/internal/tcpclient/tls_sessions.go @@ -0,0 +1,150 @@ +package tcpclient + +import ( + "crypto/tls" + "encoding/base64" + "encoding/json" + "errors" + "os" + "path/filepath" + "sync" + "time" +) + +// The pod's TLS session tickets, kept between runs of the CLI in tls_sessions.json next to the +// pins (mode 0600). Each benchpod run is a new process; without this every run pays the pod's full +// handshake (~0.46 s of ECDHE + ECDSA on the pod) instead of resuming (~30 ms). A ticket holds the +// session's secret, so the file is as private as the sign-in token next to it. Entries older than +// the pod's ticket lifetime are dropped. + +const ticketLifetime = time.Hour // the pod's ALTCP_MBEDTLS_SESSION_TICKET_TIMEOUT_SECONDS + +type savedSession struct { + Ticket string `json:"ticket"` // base64 + State string `json:"state"` // base64 of tls.SessionState.Bytes + Saved string `json:"saved"` // RFC 3339 +} + +type fileSessionCache struct { + mu sync.Mutex + mem tls.ClientSessionCache +} + +var sessionCache tls.ClientSessionCache = &fileSessionCache{mem: tls.NewLRUClientSessionCache(32)} + +func sessionsPath() (string, error) { + p, err := pinPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(p), "tls_sessions.json"), nil +} + +func (c *fileSessionCache) load() (map[string]savedSession, string) { + path, err := sessionsPath() + if err != nil { + return nil, "" + } + m := map[string]savedSession{} + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &m) + } + return m, path +} + +func (c *fileSessionCache) save(m map[string]savedSession, path string) { + if path == "" { + return + } + for k, s := range m { + if t, err := time.Parse(time.RFC3339, s.Saved); err != nil || time.Since(t) > ticketLifetime { + delete(m, k) + } + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil || os.MkdirAll(filepath.Dir(path), 0o700) != nil { + return + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".tls_sessions-*") + if err != nil { + return + } + defer os.Remove(tmp.Name()) + _ = tmp.Chmod(0o600) + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return + } + if tmp.Close() == nil { + _ = os.Rename(tmp.Name(), path) + } +} + +func (c *fileSessionCache) Get(key string) (*tls.ClientSessionState, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if cs, ok := c.mem.Get(key); ok { + return cs, true + } + m, _ := c.load() + s, ok := m[key] + if !ok { + return nil, false + } + if t, err := time.Parse(time.RFC3339, s.Saved); err != nil || time.Since(t) > ticketLifetime { + return nil, false + } + ticket, err1 := base64.StdEncoding.DecodeString(s.Ticket) + raw, err2 := base64.StdEncoding.DecodeString(s.State) + if err := errors.Join(err1, err2); err != nil { + return nil, false + } + st, err := tls.ParseSessionState(raw) + if err != nil { + return nil, false + } + cs, err := tls.NewResumptionState(ticket, st) + if err != nil { + return nil, false + } + c.mem.Put(key, cs) + return cs, true +} + +func (c *fileSessionCache) Put(key string, cs *tls.ClientSessionState) { + c.mu.Lock() + defer c.mu.Unlock() + c.mem.Put(key, cs) + m, path := c.load() + if cs == nil { + if _, ok := m[key]; !ok { + return + } + delete(m, key) + c.save(m, path) + return + } + ticket, st, err := cs.ResumptionState() + if err != nil || st == nil { + return + } + raw, err := st.Bytes() + if err != nil { + return + } + m[key] = savedSession{Ticket: base64.StdEncoding.EncodeToString(ticket), + State: base64.StdEncoding.EncodeToString(raw), Saved: time.Now().UTC().Format(time.RFC3339)} + c.save(m, path) +} + +// forgetSessions drops every saved session (a pin changed: start with a full handshake). +func forgetSessions() { + if c, ok := sessionCache.(*fileSessionCache); ok { + c.mu.Lock() + defer c.mu.Unlock() + c.mem = tls.NewLRUClientSessionCache(32) + if path, err := sessionsPath(); err == nil { + _ = os.Remove(path) + } + } +} diff --git a/internal/tcpclient/tls_sessions_test.go b/internal/tcpclient/tls_sessions_test.go new file mode 100644 index 0000000..49d642e --- /dev/null +++ b/internal/tcpclient/tls_sessions_test.go @@ -0,0 +1,63 @@ +package tcpclient + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "path/filepath" + "testing" + "time" +) + +// A ticket saved by one run resumes in the next (a fresh in-memory cache stands for a new +// process), and forgetSessions drops it. +func TestSessionTicketSurvivesTheProcess(t *testing.T) { + PinStorePath = filepath.Join(t.TempDir(), "known_pods.json") + defer func() { PinStorePath = "" }() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "pod"}, + NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)} + der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + srv := &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + MaxVersion: tls.VersionTLS12} + ln, err := tls.Listen("tcp", "127.0.0.1:0", srv) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { _ = c.(*tls.Conn).Handshake(); c.Read(make([]byte, 1)); c.Close() }(c) + } + }() + dial := func() bool { + cfg, _ := tlsConfig(ln.Addr().String(), true) + c, err := tls.Dial("tcp", ln.Addr().String(), cfg) + if err != nil { + t.Fatal(err) + } + defer c.Close() + c.Write([]byte{0}) // the TLS 1.2 ticket arrives with the handshake + return c.ConnectionState().DidResume + } + if dial() { + t.Fatal("first connection resumed") + } + sessionCache.(*fileSessionCache).mem = tls.NewLRUClientSessionCache(32) // a new process + if !dial() { + t.Fatal("the saved ticket did not resume") + } + forgetSessions() + if dial() { + t.Fatal("resumed after forgetSessions") + } +}