ssh: use local CA, add mutual container/host auth

See loop/server/local_ssh.md for a detailed description of how sketch uses
now uses a local CA to sign each container certificate instead of adding
a new entry to known_hosts for each container.

This also adds another layer of security by having the container's ssh
server verify that incoming ssh connections have valid host certificates,
whereas prior to this change the authentication was only one-way (verifying
that the sketch container you think you're ssh'ing into really is the one
you think you're ssh'ing into).

This is somewhat inspired by https://github.com/FiloSottile/mkcert - which
plays a similar role as ssh_theater.go local for ssh connections, but mkcert
uses a local CA to address local development use cases for TLS/https rather
than for ssh.

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: sc7b3928295277d5dk
diff --git a/loop/server/local_ssh.md b/loop/server/local_ssh.md
new file mode 100644
index 0000000..1a4744c
--- /dev/null
+++ b/loop/server/local_ssh.md
@@ -0,0 +1,126 @@
+# Sketch SSH Mutual Authentication System
+
+This document describes how the Sketch SSH system implements mutual authentication between the host machine and containers using certificate-based authentication.
+
+## Architecture Overview
+
+The Sketch SSH system uses a certificate-based approach to establish mutual trust between the host machine and containers. This ensures that:
+
+1. Host verifies container identity: The user can trust that they're connecting to the intended container
+2. Container verifies host identity: The container can trust that connections are coming from authorized host machines
+
+## Component Interaction Diagram
+
+```mermaid
+sequenceDiagram
+    participant User
+    participant Host as Host Machine (SSHTheater)
+    participant Container as Container (SSH Server)
+    participant Client as SSH Client (ssh/VSCode)
+
+    %% Setup phase
+    Note over Host,Container: Initialization Phase
+    Host->>Host: Generate Container CA keys
+    Host->>Host: Generate host certificate signed by Container CA
+    Host->>Host: Generate user identity keys
+    Host->>Host: Generate server identity keys
+    Host->>Container: Init Container with:<br/>- Server identity<br/>- User public key<br/>- Container CA public key<br/>- Host certificate
+    Container->>Container: Start SSH server with certificate verification
+
+    %% Connection phase
+    Note over Host,Container: Connection Phase
+    User->>Client: Request SSH connection to container
+    
+    Client->>Host: Get SSH config from ~/.ssh/config
+    Host->>Client: Config with:<br/>- Host identity path<br/>- Host certificate path
+    
+    Client->>Container: Connect with host certificate
+    
+    Container->>Container: Verify client certificate<br/>is signed by Container CA
+    
+    opt Mutual Authentication
+        Container->>Client: Challenge for host certificate
+        Client->>Container: Present host certificate
+        Container->>Container: Verify host certificate<br/>is for 'root' user<br/>from localhost only
+    end
+    
+    alt Valid Certificate
+        Container->>Client: Accept connection
+        Client->>User: Establish shell session
+    else Invalid Certificate
+        Container->>Client: Reject connection
+        Client->>User: Authentication failed
+    end
+```
+
+## Security Features
+
+### Certificate-Based Authentication
+
+- **Container CA**: A dedicated Certificate Authority for Sketch containers
+- **Host Certificates**: Signed by the Container CA, authorizing specific hosts to connect
+- **CA-Based Known Hosts**: Uses `@cert-authority localhost,127.0.0.1,[::1]` directive to trust only localhost certificates
+- **Certificate Constraints**:
+  - Valid only for 'root' user (the container user)
+  - Source address restricted to localhost (127.0.0.1, ::1)
+  - Limited validity period (30 days by default)
+
+### Security Benefits
+
+1. **Defense in Depth Security**:
+   - Multiple layers of protection:
+     - Certificates require CA signature
+     - CA authority is restricted to localhost addresses only
+     - Certificate constraints limit to localhost source addresses
+   - Even if the CA key is compromised, it can only be used for localhost connections
+   - Even if SSH ports are exposed, unauthorized connections are rejected
+
+2. **Localhost Restriction**:
+   - Host certificates only work from the local machine, preventing remote exploitation
+
+3. **Seamless User Experience**:
+   - Certificate management happens automatically, requiring no user intervention
+
+## Implementation Components
+
+- **SSHTheater**: Manages certificate generation and SSH configuration on the host
+- **Custom SSH Server**: Enforces certificate validation in the container
+- **SSH Client Configuration**: Automatically configured to use the right certificates
+
+## Key Files
+
+- `~/.config/sketch/container_ca`: Container CA private key
+- `~/.config/sketch/container_ca.pub`: Container CA public key
+- `~/.config/sketch/host_cert`: Host certificate signed by Container CA
+- `~/.config/sketch/container_user_identity`: User identity for SSH connections
+- `~/.config/sketch/container_server_identity`: Server identity for container SSH server
+
+## SSH Configuration Approach
+
+### Hybrid Configuration Model
+
+SSHTheater uses a hybrid approach to balance security and practicality:
+
+1. **Localhost-Restricted CA Trust** for host verification:
+   - A single `@cert-authority localhost,127.0.0.1,[::1]` entry in known_hosts
+   - Only trusts certificates for localhost addresses
+   - Prevents CA misuse even if keys are compromised
+   - Simplifies management of multiple containers
+   - Provides strong cryptographic verification
+
+2. **Individual Host Entries** in SSH config:
+   - Each container still needs a dedicated entry for port configuration
+   - This is necessary because SSH doesn't support wildcards for ports
+   - Container-specific entries use shared identity files and certificates
+
+This approach maintains the convenience of "ssh container-name" commands while leveraging the security benefits of certificate-based authentication.
+
+## Cleanup Behavior
+
+The system is designed to support multiple concurrent Sketch instances:
+
+- **Container-Specific Cleanup**: When a Sketch instance exits, it only removes its own container's entries from the SSH configuration
+- **Preserved CA Authority**: Certificate authority entries are preserved during cleanup since they may be used by other running containers
+- **Independent Host Certificates**: Each Sketch instance maintains its own host certificate
+
+This approach allows multiple Sketch instances to run concurrently while preserving the security benefits of certificate-based authentication.
\ No newline at end of file
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index a89557d..1cd486a 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -80,14 +80,16 @@
 }
 
 type InitRequest struct {
-	HostAddr          string `json:"host_addr"`
-	OutsideHTTP       string `json:"outside_http"`
-	GitRemoteAddr     string `json:"git_remote_addr"`
-	Commit            string `json:"commit"`
-	SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
-	SSHServerIdentity []byte `json:"ssh_server_identity"`
-	SSHAvailable      bool   `json:"ssh_available"`
-	SSHError          string `json:"ssh_error,omitempty"`
+	HostAddr           string `json:"host_addr"`
+	OutsideHTTP        string `json:"outside_http"`
+	GitRemoteAddr      string `json:"git_remote_addr"`
+	Commit             string `json:"commit"`
+	SSHAuthorizedKeys  []byte `json:"ssh_authorized_keys"`
+	SSHServerIdentity  []byte `json:"ssh_server_identity"`
+	SSHContainerCAKey  []byte `json:"ssh_container_ca_key"`
+	SSHHostCertificate []byte `json:"ssh_host_certificate"`
+	SSHAvailable       bool   `json:"ssh_available"`
+	SSHError           string `json:"ssh_error,omitempty"`
 }
 
 // Server serves sketch HTTP. Server implements http.Handler.
@@ -190,7 +192,7 @@
 		if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
 			go func() {
 				ctx := context.Background()
-				if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys); err != nil {
+				if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys, m.SSHContainerCAKey, m.SSHHostCertificate); err != nil {
 					slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
 					// Update SSH error if server fails to start
 					s.sshAvailable = false
diff --git a/loop/server/sshserver.go b/loop/server/sshserver.go
index 18837a5..65eb54b 100644
--- a/loop/server/sshserver.go
+++ b/loop/server/sshserver.go
@@ -8,7 +8,9 @@
 	"log/slog"
 	"os"
 	"os/exec"
+	"slices"
 	"syscall"
+	"time"
 	"unsafe"
 
 	"github.com/creack/pty"
@@ -22,7 +24,7 @@
 		uintptr(unsafe.Pointer(&struct{ h, w, x, y uint16 }{uint16(h), uint16(w), 0, 0})))
 }
 
-func (s *Server) ServeSSH(ctx context.Context, hostKey, authorizedKeys []byte) error {
+func (s *Server) ServeSSH(ctx context.Context, hostKey, authorizedKeys []byte, containerCAKey, hostCertificate []byte) error {
 	// Parse all authorized keys
 	allowedKeys := make([]ssh.PublicKey, 0)
 	rest := authorizedKeys
@@ -48,6 +50,33 @@
 		return fmt.Errorf("ServeSSH: no valid authorized keys found")
 	}
 
+	// Set up the certificate verifier if containerCAKey is provided
+	var certChecker *gossh.CertChecker
+	var containerCA gossh.PublicKey
+	hasMutualAuth := false
+
+	if len(containerCAKey) > 0 {
+		// Parse container CA public key
+		containerCA, _, _, _, err = ssh.ParseAuthorizedKey(containerCAKey)
+		if err != nil {
+			slog.WarnContext(ctx, "Failed to parse container CA key", slog.String("err", err.Error()))
+		} else {
+			certChecker = &gossh.CertChecker{
+				// Verify if the certificate was signed by our CA
+				IsUserAuthority: func(auth gossh.PublicKey) bool {
+					return bytes.Equal(auth.Marshal(), containerCA.Marshal())
+				},
+				// Check if a certificate has been revoked
+				IsRevoked: func(cert *gossh.Certificate) bool {
+					// We don't implement certificate revocation yet, so no certificates are revoked
+					return false
+				},
+			}
+			slog.InfoContext(ctx, "SSH server configured for mutual authentication with container CA")
+			hasMutualAuth = true
+		}
+	}
+
 	signer, err := gossh.ParsePrivateKey(hostKey)
 	if err != nil {
 		return fmt.Errorf("ServeSSH: failed to parse host private key, err: %w", err)
@@ -80,7 +109,33 @@
 		},
 		HostSigners: []ssh.Signer{signer},
 		PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
-			// Check if the provided key matches any of our allowed keys
+			// First, check if it's a certificate signed by our container CA
+			if hasMutualAuth {
+				if cert, ok := key.(*gossh.Certificate); ok {
+					// It's a certificate, check if it's valid and signed by our CA
+					if certChecker.IsUserAuthority(cert.SignatureKey) {
+						// Verify the certificate validity time
+						now := time.Now()
+						if now.Before(time.Unix(int64(cert.ValidBefore), 0)) &&
+							now.After(time.Unix(int64(cert.ValidAfter), 0)) {
+							// Check if the certificate has the right principal
+							if sliceContains(cert.ValidPrincipals, "root") {
+								slog.InfoContext(ctx, "SSH client authenticated with valid certificate")
+								return true
+							}
+							slog.WarnContext(ctx, "Certificate lacks root principal",
+								slog.Any("principals", cert.ValidPrincipals))
+						} else {
+							slog.WarnContext(ctx, "Certificate time validation failed",
+								slog.Time("now", now),
+								slog.Time("valid_after", time.Unix(int64(cert.ValidAfter), 0)),
+								slog.Time("valid_before", time.Unix(int64(cert.ValidBefore), 0)))
+						}
+					}
+				}
+			}
+
+			// Standard key-based authentication fallback
 			for _, allowedKey := range allowedKeys {
 				if ssh.KeysEqual(key, allowedKey) {
 					slog.DebugContext(ctx, "ServeSSH: allow key", slog.String("key", string(key.Marshal())))
@@ -208,3 +263,8 @@
 		s.Exit(1)
 	}
 }
+
+// sliceContains checks if a string slice contains a specific string
+func sliceContains(slice []string, value string) bool {
+	return slices.Contains(slice, value)
+}