diff --git a/README.md b/README.md index d35d200..7e7e37f 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,43 @@ 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. +- 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:`. +- `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 +674,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 +1276,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/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..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" @@ -38,7 +39,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 } @@ -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() } 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..8e553f7 --- /dev/null +++ b/internal/tcpclient/tls.go @@ -0,0 +1,437 @@ +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) + // 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 + } + 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)} + forgetSessions() + 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) + forgetSessions() + 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_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_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") + } +} diff --git a/internal/tcpclient/tls_test.go b/internal/tcpclient/tls_test.go new file mode 100644 index 0000000..adbef6d --- /dev/null +++ b/internal/tcpclient/tls_test.go @@ -0,0 +1,157 @@ +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() + // 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) + } + 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") + } +}